CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Failing after 12s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Failing after 35s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Milestone 394, steps 5-8. Operator: "remove the always on rule functionality as the goal was to not have it at all since it didn't seem to work as expected." Unconditional preload had three failures the retrieval arms do not. It could not be MEASURED — a resident rule is in the context whether or not it mattered, so nothing distinguished "this governed the act" from "this was scenery", and it was the one surface structurally exempt from the scoreboard judging every other. It was SUMMARISED AWAY by compaction while the session went on believing it held the rules. And it CROWDED OUT the few rules that applied with the thirty that did not. WHAT GOES Schema (0100): rules.tier + ck_rules_tier, rule_versions.tier, rulebooks.always_on, and project_rulebook_exclusions — a table recording a project's opt-out of something that no longer binds it unasked. Tools: list_always_on_rules, exclude_always_on_rulebook, include_always_on_rulebook. Service: the same three plus rules_etag_for, _valid_tier and the whole etag family. The SessionStart preload and the write-path staleness arm go with them: nothing is resident, so nothing can have drifted since a session loaded it. THREE CALLS WORTH REVIEWING enter_project got NARROWER, not wider. Its filter was `always_on OR area-tagged`; dropping the tier arm leaves the deterministic half, so a project with no canonical-tagged Systems gets no bulk rules and reaches them by retrieval instead. Dropping the whole clause would have made that payload bigger than the preload this milestone deletes. Backups import tolerantly. A pre-394 archive carries tier, always_on and the retired inception choice; none is read, and the exclusion key is DROPPED rather than remapped, because restoring it would write data that validate_inception now rejects as unknown. The migration is irreversible in the way that matters and says so: downgrade recreates the columns at their defaults and cannot restore which rules were always-on. A value invented to fill a hole is not a measurement. THE INSTRUCTION SURFACES SAY THE HARDER THING Deleting "call list_always_on_rules()" is easy; replacing it is not, because the new model asks a session to trust something it cannot see. All three surfaces now say a session holds nothing, that rules arrive when work matches them, and — the half that got dangerous — that "no rule arrived" means "nothing matched", never "there is no rule". Under residency an empty session was rare and suspicious; it is now the ordinary state of most turns, so reading it as permission is wrong on nearly every turn rather than occasionally. That is #3720's defect at session scale. test_instruction_surfaces_agree is repointed rather than retired: its two halves collapsed into one instruction, and it gains a guard that every surface states what absence means. _INSTRUCTIONS is back at 1999/2000 — the inception clause paid for the longer HOW line. UI (rule 27, and the opportunity step 8 named) The tier selector is gone, and what replaces it is the point: `when_to_apply` is now the field that decides whether a rule is ever seen, so the editor marks it required, warns while it is empty, and both rule lists badge a trigger-less rule "never surfaces". A rule without one is not quiet, it is unreachable. TESTS Two files deleted outright — test_rules_etag.py and test_inception_rules.py tested subsystems that no longer exist. Elsewhere obsolete cases were removed and the rest repointed. One deserves naming: the wiring test asserted the act arms pass no `tier`, which had become an assertion that could not fail. It is repointed onto `kind`, which does still exist and where the same claim is live — a preference must reach a write exactly as a rule does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011cPyzNnegXHr5iRMzzy5KJ
274 lines
11 KiB
Vue
274 lines
11 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* What a rule USED TO SAY — inside the slide-over, where a rule is read in
|
|
* full. Not on the list row: a history entry point there would compete with
|
|
* the row's actual job.
|
|
*
|
|
* A SIBLING OF HistoryPanel.vue, NOT A REUSE OF IT, and the reason is in its
|
|
* props: `noteId` + `currentBody`, a `NoteVersion` carrying tags and pin
|
|
* columns, a fetch of /api/notes/…, a `restore` emit, and pin/unpin buttons.
|
|
* Every one of those is note-shaped. Rules have no tags, no pins, and
|
|
* deliberately no restore, and a rule's text is EIGHT fields rather than one
|
|
* body — which changes the central question from "what changed" to "which
|
|
* fields moved".
|
|
*
|
|
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and
|
|
* nothing note-shaped, and the LCS walk now lives in utils/diff.ts, which
|
|
* this file uses rather than copying a fourth time (#3207).
|
|
*/
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import DiffView from "@/components/DiffView.vue";
|
|
import { computeDiff } from "@/utils/diff";
|
|
import {
|
|
listRuleVersions, getRuleVersion, type Rule, type RuleVersion,
|
|
} from "@/api/rulebooks";
|
|
import { useToastStore } from "@/stores/toast";
|
|
|
|
const props = defineProps<{ ruleId: number; current: Rule | null }>();
|
|
|
|
const toast = useToastStore();
|
|
const versions = ref<RuleVersion[]>([]);
|
|
const selected = ref<RuleVersion | null>(null);
|
|
const expanded = ref(false);
|
|
const loading = ref(false);
|
|
const loadingDetail = ref(false);
|
|
|
|
// The eight TEXT fields a version carries, in the order the editor shows
|
|
// them. Narrowed to its own type rather than `keyof RuleVersion`, which would
|
|
// also admit id/rule_id/user_id/created_at — none of which is text a reader
|
|
// compares, and all of which would widen every lookup below to `number`.
|
|
// Labels rather than column names: a reader is deciding whether to open a
|
|
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
|
type TextField =
|
|
| "title" | "statement" | "when_to_apply"
|
|
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
|
|
|
const FIELDS: Array<[TextField, string]> = [
|
|
["title", "Title"],
|
|
["statement", "Statement"],
|
|
["when_to_apply", "When to apply"],
|
|
["why", "Why"],
|
|
["how_to_apply", "How to apply"],
|
|
["verify_with", "Check"],
|
|
["expires_when", "Ends when"],
|
|
];
|
|
|
|
/**
|
|
* Which fields this edit moved.
|
|
*
|
|
* A version holds the text the edit REPLACED, so the edit is the step from
|
|
* this row to the NEXT NEWER state — the version above it in the list, or,
|
|
* for the newest row, the rule as it stands now. Comparing against the row
|
|
* below instead would attribute every change to the wrong edit.
|
|
*/
|
|
function changedFields(index: number): string[] {
|
|
const before = versions.value[index];
|
|
// `Rule` carries all eight as required strings; a RuleVersion carries them
|
|
// only once opened, which is what the undefined check below is about.
|
|
const after: Pick<Rule, TextField> | RuleVersion | null =
|
|
index === 0 ? props.current : versions.value[index - 1] ?? null;
|
|
if (!before || !after) return [];
|
|
return FIELDS
|
|
.filter(([key]) => {
|
|
// A listing row carries only the title; the rest arrive when opened.
|
|
// Undefined means NOT LOADED, which is not the same as unchanged — so a
|
|
// field nobody has fetched is claimed as neither.
|
|
const a = before[key];
|
|
const b = after[key];
|
|
if (a === undefined || b === undefined) return false;
|
|
return (a ?? "") !== (b ?? "");
|
|
})
|
|
.map(([, label]) => label);
|
|
}
|
|
|
|
/** True when this edit rewrote or removed the rule's check.
|
|
*
|
|
* Worth its own marker because editing `verify_with` silently drops
|
|
* `verified_at` (milestone 312) — the moment a rule re-entered the staleness
|
|
* sweep. That happens nowhere a reader can see it, and this row is the only
|
|
* surface that can say when it happened. */
|
|
function checkChanged(index: number): boolean {
|
|
return changedFields(index).includes("Check");
|
|
}
|
|
|
|
const diff = computed(() => {
|
|
if (!selected.value || selected.value.statement === undefined) return [];
|
|
const now = props.current?.statement ?? "";
|
|
return computeDiff(now, selected.value.statement);
|
|
});
|
|
|
|
function stamp(iso: string): string {
|
|
return iso.slice(0, 10);
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
try {
|
|
versions.value = await listRuleVersions(props.ruleId);
|
|
} catch {
|
|
toast.show("Could not load this rule's history", "error");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function open(v: RuleVersion) {
|
|
if (selected.value?.id === v.id) {
|
|
selected.value = null;
|
|
return;
|
|
}
|
|
loadingDetail.value = true;
|
|
try {
|
|
const full = await getRuleVersion(props.ruleId, v.id);
|
|
// Merged back into the list so `changedFields` can compare against real
|
|
// text once a neighbour has been opened, instead of staying blind.
|
|
const at = versions.value.findIndex((x) => x.id === v.id);
|
|
if (at >= 0) versions.value[at] = { ...versions.value[at], ...full };
|
|
selected.value = versions.value[at] ?? full;
|
|
} catch {
|
|
toast.show("Could not open that version", "error");
|
|
} finally {
|
|
loadingDetail.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(load);
|
|
watch(() => props.ruleId, () => { selected.value = null; load(); });
|
|
</script>
|
|
|
|
<template>
|
|
<section class="history">
|
|
<button class="toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
|
<span>Edit history</span>
|
|
<span class="count">{{ versions.length || "none" }}</span>
|
|
</button>
|
|
|
|
<div v-if="expanded" class="body">
|
|
<p v-if="loading" class="state">Loading…</p>
|
|
|
|
<!-- Never reworded is the ordinary case, and must not read as a fault. -->
|
|
<p v-else-if="!versions.length" class="state empty">
|
|
This rule has never been reworded. Nothing was recorded before the history
|
|
existed, so an older rule starts empty too.
|
|
</p>
|
|
|
|
<template v-else>
|
|
<p class="lede">
|
|
Each entry is what the rule said <em>before</em> that edit. The wording it
|
|
was changed to is the rule as it stands above.
|
|
</p>
|
|
<ol class="rows">
|
|
<li v-for="(v, i) in versions" :key="v.id" class="row">
|
|
<button
|
|
class="row-head"
|
|
:class="{ open: selected?.id === v.id }"
|
|
@click="open(v)"
|
|
>
|
|
<span class="when">{{ stamp(v.created_at) }}</span>
|
|
<span class="fields">
|
|
{{ changedFields(i).join(", ") || "opened to compare" }}
|
|
</span>
|
|
<span v-if="checkChanged(i)" class="check-moved">check reset</span>
|
|
</button>
|
|
|
|
<div v-if="selected?.id === v.id" class="detail">
|
|
<p v-if="loadingDetail" class="state">Loading…</p>
|
|
<template v-else>
|
|
<p v-if="checkChanged(i)" class="warn">
|
|
This edit changed the rule's check, which cleared its verification
|
|
stamp — the rule went back to the top of the staleness sweep here.
|
|
</p>
|
|
<dl class="fields-list">
|
|
<template v-for="[key, label] in FIELDS" :key="key">
|
|
<template v-if="key !== 'statement' && v[key]">
|
|
<dt>{{ label }}</dt>
|
|
<dd>{{ v[key] }}</dd>
|
|
</template>
|
|
</template>
|
|
</dl>
|
|
<h4>Statement</h4>
|
|
<DiffView v-if="diff.length" :diff="diff" />
|
|
<p v-else class="state">The statement did not change in this edit.</p>
|
|
</template>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.history { border-top: 1px solid var(--fs-border-color); padding-top: var(--fs-space-3); }
|
|
|
|
.toggle {
|
|
display: flex; align-items: center; gap: var(--fs-space-2); width: 100%;
|
|
background: none; border: none; padding: 0; cursor: pointer;
|
|
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
|
|
}
|
|
.toggle:hover { color: var(--fs-text-primary); }
|
|
.count {
|
|
margin-left: auto; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
|
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
|
.state.empty { color: var(--fs-text-tertiary); }
|
|
.lede {
|
|
margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
|
|
}
|
|
|
|
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
|
.row { background: var(--fs-surface-raised); border-radius: var(--fs-radius-md); }
|
|
|
|
.row-head {
|
|
display: flex; align-items: baseline; gap: var(--fs-space-3); width: 100%;
|
|
background: none; border: none; cursor: pointer; text-align: left;
|
|
padding: var(--fs-space-2) var(--fs-space-3);
|
|
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary);
|
|
}
|
|
.row-head:hover { background: var(--fs-surface-hover); border-radius: var(--fs-radius-md); }
|
|
.when {
|
|
font-variant-numeric: tabular-nums; color: var(--fs-text-secondary);
|
|
font-size: var(--fs-size-tiny);
|
|
}
|
|
.fields { color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
|
|
|
/* A TINT, not the solid token. `--fs-warning-fg` is defined as "warning text
|
|
ON A WARNING TINT" — painting it over solid `--fs-warning` is the same-hue
|
|
contrast failure #3141 records. The 12% mix is how theme.css builds its own
|
|
`-bg` pairs, and it keeps the value a resolvable var() rather than a raw hex
|
|
that check_design_tokens.py cannot see at all. */
|
|
.check-moved {
|
|
margin-left: auto; flex: none;
|
|
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
|
color: var(--fs-warning-fg);
|
|
border-radius: var(--fs-radius-pill);
|
|
padding: 0.1rem 0.5rem;
|
|
font-size: var(--fs-size-tiny); letter-spacing: var(--fs-tracking-tiny);
|
|
}
|
|
|
|
.detail {
|
|
padding: 0 var(--fs-space-3) var(--fs-space-3);
|
|
display: flex; flex-direction: column; gap: var(--fs-space-2);
|
|
}
|
|
.warn {
|
|
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
|
color: var(--fs-warning-fg);
|
|
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
|
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
|
|
}
|
|
.fields-list { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: 0; }
|
|
.fields-list dt {
|
|
font-size: var(--fs-size-tiny); text-transform: uppercase;
|
|
letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary);
|
|
}
|
|
.fields-list dd {
|
|
margin: 0; font-size: var(--fs-size-body-sm);
|
|
color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere;
|
|
}
|
|
h4 { margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
|
|
</style>
|