CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
Rule 27 — the schema and both doors shipped with no human surface, so step 3 was not shippable until this. RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only view of the rule's edges. The tier is a radio pair carrying the test itself rather than a bare toggle: can you name the trigger WITHOUT naming a system, an artifact type or a moment? If the honest answer is "whenever you are working", it is always on. It also says why conditional is not a demotion — it costs nothing when irrelevant, which is what lets a rule be as long as it needs to be. The relations block states the rule the whole milestone turns on: rules that FAIL TOGETHER are linked, never merged. RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and marks conditional only — always_on is the default and badging every row would say nothing. The date is the cheap triage the FabledCurator case wanted: a rule whose age predates the capability it duplicates is visible at a glance instead of needing a get_rule to find out. ProjectRulesTab's inline create form gains the same two fields, because a project rule bloats exactly the way a family one does — FabledCurator has 23 of them. Two type fixes the new shapes forced, both worth keeping: - toHeader() in the store: a list row is the server's rule_brief, so patching a list locally has to mirror every field it carries or the two disagree. There were two hand-built four-field literals doing that job. - ApplicableRules.rules / .project_rules are now described AS RuleHeader rather than as two more hand-written shapes — the same builder produces them, so the same type should describe them. groupByRulebookAndTopic skips a null-topic rule rather than widening TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null topic in that list means something is wrong upstream, and a widened type would hide it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
259 lines
9.3 KiB
TypeScript
259 lines
9.3 KiB
TypeScript
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;
|
|
/** 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;
|
|
}
|
|
|
|
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. */
|
|
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;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|