feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Successful in 9m46s
CI / Docker image builds (push) Successful in 1m46s
CI / Go (push) Failing after 15m23s
Release / Build and publish image (push) Failing after 5s

Fundamentet under Kvasir, og det er værd at have uden ham.

Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere
af samme postkasse — en anden assistent, en gentaget kørsel, et menneske —
laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal
faktura fra Browns" to gange i samme kørsel.

Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`,
`imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke
hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det
betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en
nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og
det her skal virke uden.

Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der
læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal
være ude af stand til" er en betingelse frem for en aftale, nogen husker.
Afgrænset til den, mailen kom til — to personer på samme instans, der begge
modtager tråden, får hver sin opgave, og det er rigtigt.

CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge
mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som
læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da
af standser samme sted.

`TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer
triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave,
der blev lavet af en mail og smidt væk, er også et svar.
This commit is contained in:
Kristian Wind
2026-08-22 09:19:41 +02:00
parent acd6784333
commit 3e52eea2da
5 changed files with 248 additions and 3 deletions
+37 -3
View File
@@ -32,6 +32,13 @@ type Task struct {
// CreatedBy is empty when the author's account has been deleted: `created_by`
// is ON DELETE SET NULL so the task survives the person who wrote it.
CreatedBy string
// SourceKey names the thing out there this task was made from, namespaced:
// `gmail:thread:18f2c…`. Empty for a task somebody typed.
//
// It exists so that the second reader of a mailbox — another assistant, a
// rerun, a person — can ask "has this already become a task?" and get an
// answer. CreateTask returns ErrDuplicate rather than making a second one.
SourceKey string
SortOrder float64
// SubtaskCount and SubtaskDone are what a row can say without being opened:
// that there is something underneath it, and how much of it is left. Counted
@@ -452,21 +459,48 @@ func (db *DB) CreateTask(ctx context.Context, t *Task, labelNames []string) erro
_, err := tx.ExecContext(ctx, `
INSERT INTO tasks (id, project_id, section_id, parent_id, content, description,
priority, due_date, due_datetime, due_timezone, duration_min,
recurrence_rule, assignee_id, created_by, sort_order,
recurrence_rule, assignee_id, created_by, source_key, sort_order,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.ID, t.ProjectID, nullString(t.SectionID), nullString(t.ParentID),
t.Content, t.Description, t.Priority, nullString(t.DueDate),
nullTime(t.DueDatetime), nullString(t.DueTimezone), nullInt(t.DurationMin),
nullString(t.RecurrenceRule), nullString(t.AssigneeID), nullString(t.CreatedBy),
t.SortOrder, now.Unix(), now.Unix())
t.SourceKey, t.SortOrder, now.Unix(), now.Unix())
if err != nil {
// The one broken constraint that is not a bug: this source has already
// become a task. The caller skips it rather than making a duplicate,
// which is the entire reason the index exists.
if isUniqueViolation(err) {
return ErrDuplicate
}
return err
}
return setTaskLabels(ctx, tx, t.ID, t.CreatedBy, labelNames)
})
}
// TaskIDBySource answers "has this mail already become a task?" — the question
// nothing could answer before, and the one that stops two assistants reading the
// same inbox from making two of everything. Empty id and no error means no.
//
// Deleted tasks count. A task that was made from a mail and then thrown away is
// an answer — remaking it on the next sweep would be the same duplicate wearing
// a different hat.
func (db *DB) TaskIDBySource(ctx context.Context, userID, sourceKey string) (string, error) {
if sourceKey == "" {
return "", nil
}
var id string
err := db.QueryRowContext(ctx,
`SELECT id FROM tasks WHERE created_by = ? AND source_key = ?`,
userID, sourceKey).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return id, err
}
func (db *DB) nextTaskOrder(ctx context.Context, projectID, sectionID string) (float64, error) {
var max sql.NullFloat64
var err error