CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m18s
CI & Build / Build & push image (push) Skipped
status is durable and nothing clears it, so in_progress cannot also mean "someone is on this now". The claim is that second meaning, stored as who and when so it dies on read rather than needing to be cleared. - notes.claimed_by / claimed_at / claim_touched_at / claim_session (0110). - The server stamps it on the write that is the work: reaching in_progress (update or create, via apply_status_transition) and a work log on an open task. done/cancelled/todo release it. Live while touched within CLAIM_LEASE (2h); dead on read past it, with no sweep. - The plugin's PostToolUse hook on update_task/add_task_log binds the harness's session_id (GET /api/plugin/claim-session). It binds only to a live claim the caller holds; it cannot create one. - to_dict carries `claim`. Plugin version minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
11 KiB
Python
218 lines
11 KiB
Python
import enum
|
|
from datetime import date, datetime
|
|
|
|
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
|
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from scribe.models import Base
|
|
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
|
|
|
|
|
class TaskStatus(str, enum.Enum):
|
|
todo = "todo"
|
|
in_progress = "in_progress"
|
|
done = "done"
|
|
cancelled = "cancelled"
|
|
|
|
|
|
class TaskPriority(str, enum.Enum):
|
|
none = "none"
|
|
low = "low"
|
|
medium = "medium"
|
|
high = "high"
|
|
|
|
|
|
class TaskKind(str, enum.Enum):
|
|
"""What KIND of work a task is. Mirrors CHECK notes_task_kind_check.
|
|
|
|
Every value the COLUMN may hold, including `plan`. That is deliberate:
|
|
plans became milestones in 0066, but historical plan-tasks still carry
|
|
the value and must stay readable and writable. Refusing to MINT a new
|
|
plan is a door policy (see the create/update task tools), not a
|
|
statement about what the column accepts — conflating the two would make
|
|
old rows unwritable, which is how a retired value turns into corrupt
|
|
data.
|
|
"""
|
|
|
|
work = "work"
|
|
issue = "issue"
|
|
spike = "spike"
|
|
plan = "plan"
|
|
|
|
|
|
class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|
__tablename__ = "notes"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
user_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
|
)
|
|
title: Mapped[str] = mapped_column(Text, default="")
|
|
body: Mapped[str] = mapped_column(Text, default="")
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
|
|
parent_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
# Provenance: the task/feature an issue arose from. Distinct from parent_id
|
|
# (sub-task hierarchy) — this is "what spawned this". Only meaningful for
|
|
# issues; nullable for every record.
|
|
arose_from_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
project_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
milestone_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
priority: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
# The claim — which session is working this task NOW (milestone 381).
|
|
# Orthogonal to `status`: status is the work's state and durable, the claim
|
|
# is a session's attention and dies on read once `claim_touched_at` is past
|
|
# the lease. Semantics in services/task_claims.py.
|
|
claimed_by: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
claim_touched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
claim_session: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
# WHAT KIND of record this is, on the note/entity axis. Task-ness is tracked
|
|
# by `status`, not here (person/place/list entity types removed 2026-07):
|
|
# note (default) — authored prose, findable by what it is ABOUT
|
|
# process — a stored procedure, synced to a client as a skill
|
|
# snippet — a reusable shape, with its structured fields mirrored in
|
|
# `data` and its trigger composed into the title (0070)
|
|
# lesson — a transferable insight, findable by WHEN IT APPLIES rather
|
|
# than by topic (milestone 385). Same mirror discipline as a
|
|
# snippet: the trigger lives in `data.when_to_apply` and is
|
|
# composed into the title and the head of the body, which is
|
|
# what puts it in the embedded document. A lesson is never a
|
|
# task — see `status` — because a lesson that acquired one
|
|
# would start appearing in open-work listings.
|
|
#
|
|
# DELIBERATELY UNGATED. Unlike `task_kind` there is no CHECK on this column:
|
|
# migration 0036 added it as plain Text with a server default and nothing
|
|
# has constrained it since, so rule 36 has no whitelist to expand when a
|
|
# kind is added. The vocabulary that actually decides what a reader can
|
|
# reach is `services.knowledge._FACETS`; an unrecognised value there
|
|
# resolves to a filter matching nothing, which is the intended answer to a
|
|
# typo.
|
|
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
|
# Task sub-kind — what KIND of work this is, not how it is going:
|
|
# work (default) — ships a change
|
|
# issue — corrective; something was broken (0065)
|
|
# spike — time-boxed, and its output is KNOWLEDGE rather than a change;
|
|
# it succeeds by producing an answer, and nothing ships (0091)
|
|
# plan — retired since 0066 (plans are milestones), kept in the CHECK
|
|
# so historical plan-tasks stay writable
|
|
# Only meaningful when the note is a task (status is not None); ordinary
|
|
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
|
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
|
|
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
|
# Queryable structured fields for typed records — currently snippets, whose
|
|
# name/language/signature/locations live here so they can be INDEXED. The
|
|
# body keeps the same facts in readable markdown and remains what gets
|
|
# embedded; this is a mirror for querying, not the source of truth for
|
|
# display — and it is DERIVED, so every path that writes a snippet's body
|
|
# rewrites it too (services/snippets.recompose_data, called from
|
|
# notes.update_note). 0070 left it NULL on existing rows and
|
|
# snippets.backfill_snippet_data filled them at startup; readers still fall
|
|
# back to parsing the body when it is absent (snippet_fields).
|
|
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
|
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
|
|
# 317, migration 0092) — the same trio `rules` carries, and for the same
|
|
# reason. A norm is a decision: no truth value, changes only when its
|
|
# author changes it. A constraint asserts a fact about someone else's
|
|
# software and goes false with nobody watching. Only constraints get a
|
|
# check.
|
|
#
|
|
# `verify_with` is how to check it is still true; `expires_when` is the
|
|
# STATE that ends it, deliberately not a date — constraints expire when
|
|
# the ground moves, not on a schedule. `verified_at` NULL means never
|
|
# checked and sorts FIRST in the sweep: unexamined outranks
|
|
# examined-long-ago.
|
|
#
|
|
# These sit on `notes`, so every kind of row in this table has them, but
|
|
# only non-task, non-snippet records are OFFERED them (gated in
|
|
# services/notes.py). A task's decay is its status — a done issue records
|
|
# what happened and cannot go false — and a snippet already carries a
|
|
# richer, location-aware verdict in `data.verification`. On those rows
|
|
# these stay null, which is also what they mean.
|
|
#
|
|
# Most notes should leave all three empty. A null `verify_with` is not a
|
|
# gap; it is the marker for "this is a decision, there is nothing to go
|
|
# and check", and the sweep is only worth reading while that holds.
|
|
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
verified_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
|
Index("ix_notes_status", "status"),
|
|
Index("ix_notes_title", "title"),
|
|
Index("ix_notes_user_id", "user_id"),
|
|
Index("ix_notes_project_id", "project_id"),
|
|
Index("ix_notes_milestone_id", "milestone_id"),
|
|
Index("ix_notes_note_type", "note_type"),
|
|
Index("ix_notes_arose_from_id", "arose_from_id"),
|
|
# Containment queries into `data` — e.g. which snippets name a given
|
|
# repo/path in their locations. See migration 0070.
|
|
Index("ix_notes_data_gin", "data", postgresql_using="gin"),
|
|
)
|
|
|
|
@property
|
|
def is_task(self) -> bool:
|
|
return self.status is not None
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"title": self.title,
|
|
"body": self.body,
|
|
"description": self.description,
|
|
"tags": self.tags or [],
|
|
"parent_id": self.parent_id,
|
|
"arose_from_id": self.arose_from_id,
|
|
"project_id": self.project_id,
|
|
"milestone_id": self.milestone_id,
|
|
"status": self.status,
|
|
"priority": self.priority,
|
|
"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": iso(self.recurrence_next_spawn_at),
|
|
"claim": _claim_state(self),
|
|
"is_task": self.is_task,
|
|
"note_type": self.note_type or "note",
|
|
"task_kind": self.task_kind,
|
|
# Serialized unconditionally, like every other field a given row
|
|
# kind may not use (recurrence, started_at, the task fields). The
|
|
# DERIVED "last_verified" label is the one that appears only when
|
|
# a check exists — a raw projection of the row should not make a
|
|
# client branch on which keys are present.
|
|
"verify_with": self.verify_with or "",
|
|
"expires_when": self.expires_when or "",
|
|
"verified_at": iso(self.verified_at),
|
|
"created_at": iso(self.created_at),
|
|
"updated_at": iso(self.updated_at),
|
|
}
|
|
|
|
|
|
def _claim_state(note: "Note") -> dict | None:
|
|
from scribe.services.task_claims import claim_state
|
|
|
|
return claim_state(note)
|