CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 32s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 33s
Milestone 333 step 5, and rule 27 — the counter had a tuning point from step 4 and no operator-facing one until now. The task said to reuse the snippet badge's classes rather than mint a parallel set, citing the eight duplicated CSS families the ledger already carries (#3207). `.usage-tag` lived in SnippetListView's SCOPED block, so "reuse" was not available: copying it into the rule pane would have been the ninth family, and importing it is not a thing a scoped block permits. So it was promoted rather than copied. Three pieces, each of which existed once and now exists once: - `components.css` gains `.usage-tag` / `.usage-dead`, geometry and colour only, with the scoped original deleted rather than left behind. - `UsageBadge.vue` holds the logic the two lists would otherwise duplicate — the >=3 dead-weight threshold, the empty-string-renders-nothing rule, the tooltip. - `types/usage.ts` holds `RecordUsage`, one client type over two tables. `SnippetUsage` becomes an alias, so no existing consumer changes. THE ADVICE IS A PROP, and that is the substance rather than the plumbing. The counts read identically for every kind; the remedy does not. A snippet offered and never opened should probably be rewritten or deleted — one action. A rule in the same position has TWO possible causes and the operator has to pick: its trigger may fire on the wrong work, in which case `when_to_apply` wants rewording, or it may genuinely not be wanted. Baking "delete it" into the component would give the wrong nudge half the time on the surface where being wrong is most expensive, since a deleted rule stops binding behaviour. The route zero-fills every row through `usage_for_rules`, one aggregate per page — per-row would be N+1 by construction. That matters more here than for snippets: every rule on every existing install predates `rule_usage_events`, so the zero-filled shape IS the common case for a while, and a route that attached the key only where it found events would leave the badge reading undefined on almost every row. `usage_for_rules` had no test at all — step 1 covered the write path and the zero shape and left the aggregate uncovered, which only became load-bearing when a list started rendering it. It now has an integration test over real Postgres, including that a rule with no events comes back zero-filled rather than absent. Recorded as snippet #3460, per the design system's own instruction that the component layer lives as snippets rather than as prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcCs1CcQ1ormdnzSshKqvN
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import type { RecordUsage } from "@/types/usage";
|
|
|
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
|
|
/** How a rule reaches a session (milestone 307). */
|
|
export type RuleTier = "always_on" | "conditional";
|
|
|
|
/**
|
|
* A typed edge between two rules. Each kind exists because its absence forced
|
|
* a workaround: merging two rules into one row, writing an override as a
|
|
* near-copy, or leaving a local addendum with nothing to say it is one.
|
|
*/
|
|
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
|
|
|
export interface RuleRelation {
|
|
id: number;
|
|
kind: RuleRelationKind;
|
|
/** The rule at the OTHER end. */
|
|
rule_id: number;
|
|
direction: "outgoing" | "incoming";
|
|
note: string;
|
|
}
|
|
|
|
export interface Rulebook {
|
|
id: number;
|
|
owner_user_id: number;
|
|
title: string;
|
|
description: string;
|
|
always_on: boolean;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export interface RulebookTopic {
|
|
id: number;
|
|
rulebook_id: number;
|
|
title: string;
|
|
description: string;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export interface Rule {
|
|
id: number;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
title: string;
|
|
statement: string;
|
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
|
when_to_apply: string;
|
|
/**
|
|
* always_on preloads into every session; conditional is reachable and
|
|
* surfaced when its trigger fires. A rule with no tier set behaves as
|
|
* always_on, which is how every rule behaved before this existed.
|
|
*/
|
|
tier: RuleTier;
|
|
why: string;
|
|
how_to_apply: string;
|
|
/**
|
|
* How to check the rule is still true, and the state that ends it. Set
|
|
* only on a rule that asserts a fact about something outside the
|
|
* operator's control; empty on a rule that is a decision, which is most
|
|
* of them. Empty is meaningful, not missing.
|
|
*/
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** When the check last passed. Null means never checked. */
|
|
verified_at: string | null;
|
|
/** The note or task that caused this rule, if one was recorded. */
|
|
arose_from_id: number | null;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
/** Present only when the rule has them (the server omits empty keys). */
|
|
systems?: { id: number; name: string }[];
|
|
relations?: RuleRelation[];
|
|
}
|
|
|
|
/**
|
|
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
|
* age deliberately: a rule written before the capability it duplicates is
|
|
* otherwise indistinguishable, at a glance, from one still doing work.
|
|
*/
|
|
export interface RuleHeader {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
topic_id: number | null;
|
|
tier: RuleTier;
|
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
|
updated_at: string | null;
|
|
when_to_apply?: string;
|
|
arose_from_id?: number;
|
|
/**
|
|
* Present ONLY on a rule that carries a check — the presence of the key
|
|
* is itself the signal that this rule asserts a fact that can go false.
|
|
* A date (YYYY-MM-DD), or the literal "never".
|
|
*/
|
|
last_verified?: string;
|
|
/**
|
|
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
|
* Zero-filled by the list route, so a rule predating the table reads as
|
|
* "never surfaced" rather than as a missing field — which for a while is
|
|
* every rule on every install.
|
|
*/
|
|
usage?: RecordUsage;
|
|
}
|
|
|
|
export interface ApplicableRules {
|
|
// Both lists are rule_brief's output — the SAME builder, so they are
|
|
// described the same way here rather than as two hand-written shapes that
|
|
// drift from it and from each other (which is what the server side had).
|
|
rules: (RuleHeader & {
|
|
topic_title: string;
|
|
rulebook_id: number;
|
|
rulebook_title: string;
|
|
})[];
|
|
project_rules: RuleHeader[];
|
|
suppressed_rules: {
|
|
id: number;
|
|
title: string;
|
|
topic_id: number;
|
|
topic_title: string;
|
|
rulebook_id: number;
|
|
rulebook_title: string;
|
|
}[];
|
|
suppressed_topics: {
|
|
id: number;
|
|
title: string;
|
|
rulebook_id: number;
|
|
rulebook_title: string;
|
|
}[];
|
|
truncated: boolean;
|
|
subscribed_rulebooks: { id: number; title: string }[];
|
|
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
|
excluded_always_on: { id: number; title: string }[];
|
|
}
|
|
|
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
|
|
|
export async function listRulebooks(): Promise<Rulebook[]> {
|
|
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
|
|
return data.rulebooks;
|
|
}
|
|
|
|
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
|
|
return apiGet(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
|
|
return apiPost("/api/rulebooks", data);
|
|
}
|
|
|
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
|
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
|
}
|
|
|
|
export async function deleteRulebook(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
// ── Topics ─────────────────────────────────────────────────────────
|
|
|
|
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
|
|
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
|
|
return data.topics;
|
|
}
|
|
|
|
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
|
|
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
|
|
}
|
|
|
|
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
|
|
return apiPatch(`/api/rulebook-topics/${id}`, data);
|
|
}
|
|
|
|
export async function deleteTopic(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebook-topics/${id}`);
|
|
}
|
|
|
|
// ── Rules ──────────────────────────────────────────────────────────
|
|
|
|
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
|
|
const params = new URLSearchParams();
|
|
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
|
|
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
|
|
if (filters.project_id) params.set("project_id", String(filters.project_id));
|
|
const qs = params.toString();
|
|
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
|
|
return data.rules;
|
|
}
|
|
|
|
export async function getRule(id: number): Promise<Rule> {
|
|
return apiGet(`/api/rules/${id}`);
|
|
}
|
|
|
|
/**
|
|
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
|
*
|
|
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
|
* empty string to NULL, so an emptied form input does what it looks like it
|
|
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
|
* clear_fields list instead; the two idioms reach the same state.)
|
|
*/
|
|
export interface RuleWrite {
|
|
title: string;
|
|
statement: string;
|
|
when_to_apply: string;
|
|
tier: RuleTier;
|
|
why: string;
|
|
how_to_apply: string;
|
|
order_index: number;
|
|
system_ids: number[];
|
|
arose_from_id: number | null;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
}
|
|
|
|
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
|
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
|
}
|
|
|
|
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
|
return apiPatch(`/api/rules/${id}`, data);
|
|
}
|
|
|
|
/** Draw a typed edge from one rule to another. Idempotent. */
|
|
export async function relateRules(
|
|
fromRuleId: number,
|
|
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
|
): Promise<{ id: number }> {
|
|
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
|
}
|
|
|
|
export async function unrelateRules(relationId: number): Promise<void> {
|
|
return apiDelete(`/api/rule-relations/${relationId}`);
|
|
}
|
|
|
|
/**
|
|
* One entry in a rule's edit history.
|
|
*
|
|
* Each entry holds the text the edit REPLACED, not the text it introduced —
|
|
* so the newest entry is what the rule said before its most recent change,
|
|
* and what that change produced is the rule as it stands now. Read the other
|
|
* way round, every diff comes out backwards.
|
|
*
|
|
* The listing form omits the long fields; open one to get them.
|
|
*/
|
|
export interface RuleVersion {
|
|
id: number;
|
|
rule_id: number;
|
|
/** Who made the edit. Null when that account has since been deleted. */
|
|
user_id: number | null;
|
|
title: string;
|
|
created_at: string;
|
|
statement?: string;
|
|
why?: string;
|
|
how_to_apply?: string;
|
|
when_to_apply?: string;
|
|
tier?: string;
|
|
verify_with?: string;
|
|
expires_when?: string;
|
|
}
|
|
|
|
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
|
|
const data = await apiGet<{ versions: RuleVersion[] }>(
|
|
`/api/rules/${ruleId}/versions`,
|
|
);
|
|
return data.versions;
|
|
}
|
|
|
|
export async function getRuleVersion(
|
|
ruleId: number, versionId: number,
|
|
): Promise<RuleVersion> {
|
|
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
|
}
|
|
|
|
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
|
|
// back goes through updateRule, which snapshots what it replaces — so the
|
|
// undo stays visible in the history like any other edit.
|
|
|
|
export async function deleteRule(id: number): Promise<void> {
|
|
return apiDelete(`/api/rules/${id}`);
|
|
}
|
|
|
|
// ── Subscriptions ──────────────────────────────────────────────────
|
|
|
|
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
|
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
|
|
}
|
|
|
|
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
|
|
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
|
|
}
|
|
|
|
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
|
return apiGet(`/api/projects/${projectId}/rules`);
|
|
}
|
|
|
|
export async function createProjectRule(
|
|
projectId: number,
|
|
data: Partial<RuleWrite> & { statement: string },
|
|
): Promise<Rule> {
|
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
|
}
|
|
|
|
// ── Suppressions ───────────────────────────────────────────────────
|
|
|
|
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
|
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
|
|
}
|
|
|
|
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
|
|
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
|
|
}
|
|
|
|
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
|
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
|
|
}
|
|
|
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
|
}
|
|
|
|
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
|
|
|
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
|
}
|
|
|
|
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
|
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
|
}
|
|
|
|
|
|
/**
|
|
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
|
* in full — the reader is about to go and run it, so the text is the point
|
|
* of the payload rather than the bloat a listing avoids.
|
|
*/
|
|
export interface RuleVerificationRow {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
tier: RuleTier;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
when_to_apply: string;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** A date (YYYY-MM-DD), or the literal "never". */
|
|
last_verified: string | null;
|
|
/** Null when never verified — "never" is not zero days ago. */
|
|
days_since_verified: number | null;
|
|
}
|
|
|
|
/**
|
|
* Rules asserting a fact that may have gone false, oldest verification
|
|
* first, never-checked at the top. Rules without a check never appear:
|
|
* they are decisions, and there is nothing to go and check.
|
|
*
|
|
* Not filterable by project — a project reaches rules through project
|
|
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
|
|
* missing one of those paths would under-report.
|
|
*/
|
|
export async function listRulesDueForVerification(opts: {
|
|
olderThanDays?: number;
|
|
tier?: RuleTier;
|
|
neverOnly?: boolean;
|
|
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
|
const q = new URLSearchParams();
|
|
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
|
if (opts.tier) q.set("tier", opts.tier);
|
|
if (opts.neverOnly) q.set("never_only", "true");
|
|
const qs = q.toString();
|
|
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
/**
|
|
* Record that a rule's check was RUN, and what it said.
|
|
*
|
|
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
|
* is not in a recordable state, it is wrong — so it stays at the top of the
|
|
* sweep until someone corrects or retires it.
|
|
*/
|
|
export async function markRuleVerified(
|
|
id: number, stillTrue = true,
|
|
): Promise<Rule & { verified: boolean }> {
|
|
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
|
}
|