/** * TaskLedger - the operator-owned native work-item ledger (M8 Task 0.1). * * Extends the shape of Kagemusha's proven task store with assignment, * idempotency, temporal scheduling, durable generation, effect receipt, and * workorder state. Implements the pre-existing `TaskSource` interface * (operator-interfaces.ts) so the board projects one task model, not two. * * Reconcile runs create/update rows through the task_create/task_update gateway * tools; the pipeline board slot is a projection of `list({order: * 'deadline_priority'})`. The agent proposes task changes, while this ledger * enforces revisions, temporal ownership, idempotency, and atomic receipts. * * Schema-extension note: CREATE TABLE IF NOT EXISTS is a no-op on existing * tables. Any post-ship column addition needs an explicit ALTER TABLE guarded * by a PRAGMA table_info check, added to runMigration(). * * The db handle is SHARED with TriggerRegistry and owned by the caller * (start.ts opens and closes it once) - deliberately no close() here. */ import { type ChangeCoverage, type EffectQuery, type EffectRecord, type EffectTarget } from '../evidence/effects.js'; import type { SQLiteDatabase } from '../sqlite.js'; import type { OperatorTask, TaskSource } from './operator-interfaces.js'; import { type TemporalEffectReceipt, type TemporalEvidenceAttestation, type TemporalReconcileInput, type TemporalWorkContext } from './temporal-effect.js'; import { type TemporalState } from './task-temporal.js'; import type { BindingCandidate, CandidateTaskSnapshot, ExistingExternalBindingSnapshot, LifecycleCandidate, TaskHintLookup } from './external-lifecycle.js'; export declare const TASK_STATUSES: readonly ["pending", "in_progress", "review", "blocked", "done", "cancelled", "failed"]; export type TaskStatus = (typeof TASK_STATUSES)[number]; export declare const TASK_PRIORITIES: readonly ["high", "normal", "low"]; export type TaskPriority = (typeof TASK_PRIORITIES)[number]; export declare const TASK_KINDS: readonly ["owner", "system"]; export type TaskKind = (typeof TASK_KINDS)[number]; export declare const WORKORDER_KINDS: readonly ["board", "wiki", "memory-curation", "temporal"]; export type WorkOrderKind = (typeof WORKORDER_KINDS)[number]; export declare const TEMPORAL_WORKORDER_MAX_ATTEMPTS = 3; /** source_channel namespace for workorder rows: 'workorder:'. */ export declare const WORKORDER_CHANNEL_PREFIX = "workorder:"; export interface EnqueueWorkOrderInput { workKind: WorkOrderKind; /** Per-occurrence idempotency key (schedule slot / event batch / manual ts). */ idempotencyKey: string; /** Kind-specific payload; `attempts` is managed by the ledger (starts at 1). */ input: Record; priority?: TaskPriority; } export interface EnqueueTemporalGenerationInput { generationKey: string; taskId: number; temporalEpoch: number; occurrenceKey: string; checkAt: number; sourceChannel: string | null; sourceEventId: string | null; priority?: TaskPriority; } export interface WorkOrderRecord { id: number; workKind: WorkOrderKind; status: TaskStatus; priority: TaskPriority; idempotencyKey: string; /** Parsed payload; always carries `attempts` (>= 1). */ payload: Record & { attempts: number; }; createdAt: number; updatedAt: number; } /** Extended record: satisfies OperatorTask (numeric deadline) and carries the ISO original. */ export interface TaskRecord extends OperatorTask { status: TaskStatus; priority: TaskPriority; kind: TaskKind; /** ISO YYYY-MM-DD as stored; `deadline` (OperatorTask) is its UTC-midnight epoch ms. */ deadlineIso: string | null; assignee: string | null; sourceChannel: string | null; sourceEventId: string | null; latestEvent: string | null; autoCreated: boolean; confirmed: boolean; dueAt: number | null; deadlineOffsetMinutes: number | null; revision: number; temporalEpoch: number; temporalReconciledOccurrenceKey: string | null; lastTemporalCheckedAt: number | null; nextTemporalCheckAt: number | null; lastTemporalAttemptId: number | null; temporalState: TemporalState; } export interface TaskLedgerOptions { now?: () => number; timeZone?: string; } /** * Who is making a change, supplied by the host rather than by the caller's arguments. * * Kept out of CreateTaskInput/UpdateTaskInput on purpose: those are agent-authored, and an * agent that can write its own run id into the effect ledger can attribute its work to * someone else's run. Same reason the temporal path takes its identity from trusted runtime * context instead of from the tool call. */ export interface ChangeOrigin { /** The model run behind this write, or null when the host itself made it. */ runId?: string | null; /** * Host-issued board work-order attempt. This is deliberately separate from * agent-authored update input: it lets the ledger enforce candidate-scoped * mutation rules for direct and nested gateway execution alike. */ workOrderAttemptId?: number; /** * WHY an id-less change happened (S2 closed set). Ignored when * causeEventIds carry - ids always mean 'event'. Callers state it; * the ledger's only fallback is 'owner_message' (the human-adjacent * bucket - a wrong 'clock' fabricates a schedule that never fired). */ causeKind?: import('../evidence/effects.js').UnattributedCauseKind; /** * The events this write responds to. * * A SET, because a cause is one: a reconcile run is handed a channel's delta batch and * everything it changes rests on that batch. The system knows the batch before the run * starts, so it does not have to ask the agent to restate it - which is what a single * agent-supplied id was, and why 375 of 381 unattributed changes were updates. */ causeEventIds?: readonly string[]; } export interface ExternalTaskBinding { id: number; revision: number; taskId: number; connector: 'kagemusha'; sourceType: 'kanban_card'; externalSourceId: string; lastObservationSeq: number; createdByAttemptId: number; } export interface ExternalBindingReceipt { kind: 'binding'; candidateId: string; workOrderAttemptId: number; taskId: number; outcome: 'bound' | 'declined' | 'superseded'; reason: string; bindingId?: number; } export interface ExternalLifecycleReceipt { kind: 'lifecycle'; candidateId: string; workOrderAttemptId: number; taskId: number; outcome: 'applied' | 'retained' | 'superseded'; reason: string; taskRevisionBefore: number; taskRevisionAfter: number; } export type TemporalGenerationDisposition = 'active' | 'resolved' | 'final_no_update' | 'deferred' | 'exhausted' | 'superseded'; export interface TemporalGenerationRecord { generationKey: string; taskId: number; temporalEpoch: number; occurrenceKey: string; checkAt: number; disposition: TemporalGenerationDisposition; lastWorkOrderId: number | null; reason: string | null; createdAt: number; updatedAt: number; } export type { TemporalWorkContext } from './temporal-effect.js'; export interface TemporalGenerationEnqueueResult { generation: TemporalGenerationRecord; workOrder: WorkOrderRecord; created: boolean; } /** Authoritative terminal-arbitration view for one temporal attempt. */ export interface TemporalAttemptState { workOrder: WorkOrderRecord; generation: TemporalGenerationRecord; receipt: TemporalEffectReceipt | null; } export type TemporalWorkFailureResult = { disposition: 'requeued'; replacement: WorkOrderRecord; attempt: number; maxAttempts: number; } | { disposition: 'exhausted'; attempt: number; maxAttempts: number; retrySuppressed?: boolean; } | { disposition: 'superseded'; attempt: number; maxAttempts: number; }; /** Durable receipt view for one candidate-bearing board attempt. */ export type BoardCandidateAttemptState = { disposition: 'none'; } | { disposition: 'complete'; outcomes: readonly string[]; } | { disposition: 'partial'; missingCandidateIds: readonly string[]; } | { disposition: 'zero'; }; export interface CreateTaskInput { title: string; status?: TaskStatus; priority?: TaskPriority; assignee?: string; /** ISO YYYY-MM-DD */ deadline?: string; /** RFC 3339 with an explicit Z or numeric offset. */ due_at?: string; /** channelKey: ":" */ source_channel?: string; /** Idempotency key from the connector event; duplicate (channel, event) UPSERTS. */ source_event_id?: string; latest_event?: string; confirmed?: boolean; } export interface UpdateTaskInput { status?: TaskStatus; priority?: TaskPriority; assignee?: string | null; deadline?: string | null; /** RFC 3339 with an explicit Z or numeric offset. */ due_at?: string | null; latest_event?: string; confirmed?: boolean; title?: string; } export interface ListTasksFilter { status?: TaskStatus; channel?: string; search?: string; limit?: number; /** 'deadline_priority' = deadline asc NULLS LAST, then high>normal>low, then id. */ order?: 'deadline_priority' | 'updated'; } export interface ListTasksPageFilter extends ListTasksFilter { /** Opaque keyset cursor from a previous page's nextCursor. */ cursor?: string; } /** * One page plus the numbers a caller needs to PROVE it read everything. * * `list()` alone cannot support a whole-situation claim: it clamps to 200 and * defaults to 50, so a caller reading a 150-row board saw a third of it and could * not tell. Accumulating pages until nextCursor is null, with the unique ids * collected equal to total, is that proof. */ export interface ListTasksPage { tasks: TaskRecord[]; /** Rows matching the filter, ignoring limit and cursor. */ total: number; returned: number; /** Cursor for the next page, or null when this page ends the set. */ nextCursor: string | null; } export declare class TaskLedger implements TaskSource { private db; private now; private timeZone; constructor(db: SQLiteDatabase, options?: TaskLedgerOptions); private toRecord; private runMigration; /** Full current column set - single source for CREATE and the rebuild copy. */ private static readonly TABLE_COLUMNS_SQL; private static readonly INDEXES_SQL; /** * Stage-2 in-place upgrade for pre-existing tables. Guards are re-checked * INSIDE `BEGIN IMMEDIATE`: two connections construct TaskLedger (boot + * lazy API handler) and both run this - the loser must see the winner's * finished work, not rebuild an already-migrated table. */ private upgradeSchema; /** TaskSource conformance: open items in canonical board order. */ getTasks(): OperatorTask[]; countOpenUnconfirmed(): number; list(filter?: ListTasksFilter): TaskRecord[]; /** Shared filter predicate. Owner surface only - system workorder rows never appear in * board/REST/gateway listings (Stage-2 kind filter; workorders have dedicated readers). */ private buildListPredicate; /** ORDER BY for both list() and listPage(); the trailing id makes it a TOTAL order, * which is what lets a keyset cursor resume without skipping or repeating a row. * LIMIT applies AFTER ordering, so a bounded read still returns the true top-N. */ private static orderSql; private static cursorFor; /** * One page of the same ordered set, with the totals that make completeness provable. * * Keyset, not OFFSET: an offset page silently skips or repeats rows when the set * changes between pages, which would leave a "whole situation" claim resting on a * read that quietly lost items. */ listPage(filter?: ListTasksPageFilter): ListTasksPage; /** Internal bounded page for temporal reconciliation; excludes rows that can never be candidates. */ listTemporalScanPage(input: { limit: number; afterId: number; }): TaskRecord[]; /** Owner rows only - system workorder rows are invisible to external reads. */ getById(id: number): TaskRecord | null; /** Internal fetch without the kind filter (guards and workorder paths). */ private getRowById; /** Returns one active, explicitly receipted external identity for an owner task. */ getExternalBinding(taskId: number): ExternalTaskBinding | null; /** * Host-only lookup material for immutable external-lifecycle candidates. * The caller supplies exact event/source ids obtained from the connector index; * no connector prose is read through this ledger surface. */ getExternalLifecycleCandidateSupport(eventIds: readonly string[], externalSourceIds: readonly string[]): { taskHints: TaskHintLookup; tasksById: ReadonlyMap; bindings: readonly ExistingExternalBindingSnapshot[]; }; getReceiptedExternalCandidateIds(candidateIds: readonly string[]): ReadonlySet; /** * Recover a candidate only from the payload of the exact currently claimed * board attempt. Revalidate the serialized payload: database bytes are not * a capability merely because they originated in a previous host process. */ loadBoardCandidate(attemptId: number, candidateId: string, kind: 'binding' | 'lifecycle'): BindingCandidate | LifecycleCandidate; /** * Read the complete, immutable candidate set for a board attempt against * its durable decision receipts. This deliberately works for stale claims * as well as live in-progress attempts: recovery must never infer effects * from a runner result when the database can answer exactly. */ inspectBoardCandidateAttempt(attemptId: number): BoardCandidateAttemptState; applyExternalBindingDecision(attemptId: number, input: { candidate_id: string; decision: 'bind' | 'decline'; reason: string; expected_revision: number; }, origin: ChangeOrigin): ExternalBindingReceipt; applyExternalLifecycleDecision(attemptId: number, input: { candidate_id: string; decision: 'apply' | 'retain'; reason: string; expected_revision: number; }, origin: ChangeOrigin): ExternalLifecycleReceipt; getExternalCandidateReceipt(candidateId: string): ExternalBindingReceipt | ExternalLifecycleReceipt | null; private externalBindingFromRow; private assertBindingReplayMatches; private insertBindingReceipt; private assertLifecycleReplayMatches; private insertLifecycleReceipt; /** * Create a task. Idempotent under at-least-once delivery: a duplicate * (source_channel, source_event_id) UPSERTS - the existing row gets the new * latest_event (and title stays) instead of a near-duplicate row appearing. */ create(input: CreateTaskInput, origin?: ChangeOrigin): TaskRecord; /** * Write the effect row for a task change. * * Called from inside the same transaction as the write it describes. The cause is the * event the caller already recorded on the row - the tool has always accepted it, and * 178 of the 179 owner tasks that carry one resolve to a real indexed event, so this is * attribution that already exists rather than a new burden on the agent. * * When there is no cause the change is still recorded, marked unattributed. That is the * honest reading of `task_update`, which has never had a cause field at all: the system * has been changing owner work items without recording why, and the ledger's job is to * show how often, not to pretend otherwise or to start refusing writes. */ private recordTaskChange; update(id: number, patch: UpdateTaskInput, origin?: ChangeOrigin): TaskRecord; /** * The one mutation primitive for owner-task state. Callers own the outer * transaction so lifecycle receipts, effects, generation ownership, and the * row revision can commit or roll back as one unit. */ private transitionTaskInTransaction; private validateTaskUpdatePatch; private transitionOwnerTaskRowInTransaction; private assertCandidateTaskMutationAllowed; private supersedeTemporalGenerationsInTransaction; private supersedeAllActiveTemporalGenerationsInTransaction; /** * Stable hash over ordered OWNER rows - the Phase-2 verifier's ledger * snapshot. kind filter keeps concurrent system enqueues from shaking the * hash mid-bracket (evidence-only signal, but noise is noise). */ payloadHash(): string; /** * Enqueue a workorder. Idempotent per occurrence key: an open (pending or * in_progress) keyed row dedups; a terminal keyed row frees the slot (the * unique index excludes terminal statuses) and a fresh row is inserted. */ enqueueWorkOrder(order: EnqueueWorkOrderInput): WorkOrderRecord; private insertWorkOrder; enqueueTemporalGeneration(input: EnqueueTemporalGenerationInput): TemporalGenerationEnqueueResult; getTemporalGeneration(generationKey: string): TemporalGenerationRecord | null; loadTemporalWorkContext(attemptId: number): TemporalWorkContext; assertTemporalWorkContextActive(suppliedContext: TemporalWorkContext): TemporalWorkContext; /** * What this system changed, and what it changed it for. * * Reads live beside the writes because the effect ledger shares the tasks database on * purpose. Exposed here rather than by handing out the connection: the ledger owns both * halves, so a change and its account of itself cannot drift apart. */ listChanges(query?: EffectQuery): EffectRecord[]; /** Of what this system changed, how much rests on evidence. */ changeCoverage(sinceMs?: number, targetType?: EffectTarget): ChangeCoverage; getTemporalEffect(attemptId: number): TemporalEffectReceipt | null; /** * Read and validate the durable state that decides a temporal attempt's * terminal outcome. This intentionally does not require an active attempt: * consumers must be able to arbitrate committed, superseded, and exhausted * attempts after runner/auditor failures and daemon restarts. */ inspectTemporalAttempt(attemptId: number): TemporalAttemptState; applyTemporalEffect(suppliedContext: TemporalWorkContext, input: TemporalReconcileInput, evidence: TemporalEvidenceAttestation, now?: number): TemporalEffectReceipt; requeueTemporalWorkOrder(attemptId: number, reason: string): WorkOrderRecord; exhaustTemporalWorkOrder(attemptId: number, reason: string): void; failTemporalWorkOrder(attemptId: number, reason: string, allowRetry?: boolean): TemporalWorkFailureResult; private repairClosedTemporalOwnershipInTransaction; /** Repair pre-fix databases where a terminal owner still has active temporal ownership. */ repairClosedTemporalGenerations(): number; /** Control-plane pause: cancel open attempts but keep generations resumable. */ pauseActiveTemporalWork(reason: string): number; /** Resume paused active generations without spending another model attempt. */ resumePausedTemporalWork(): WorkOrderRecord[]; private requeueTemporalWorkOrderInTransaction; private exhaustTemporalWorkOrderInTransaction; supersedeTemporalGenerations(taskId: number, currentEpoch: number, excludeGenerationKey?: string): void; private validateTemporalGenerationInput; /** * Workorder payloads carry only bounded references to owner-source identifiers. * Owner rows created by older releases may contain empty or arbitrarily long * connector identifiers; hashing preserves exact identity without making one * legacy row able to abort temporal boot or inflate every retry payload. */ private temporalSourceIdentifierRef; private validateTemporalEffectInput; private assertTemporalContextMatches; private loadTemporalWorkContextInternal; private assertTemporalPayloadMatches; private requirePayloadString; private requireNullablePayloadString; private requirePayloadInteger; private assertTemporalReason; private temporalAuditText; /** * Claim the next pending workorder: priority high>normal>low, then id ASC * (plan D2/E2 - CASE mapping, never lexicographic on the TEXT enum). * pending -> in_progress. Single serial consumer; transaction for atomicity. */ claimNextWorkOrder(): WorkOrderRecord | null; /** * Atomic fail-and-requeue (PR bot round): the failure mark and the * replacement row commit together - a crash between the two would lose * the retry (the old row terminal, the new one never inserted). The * replacement can only be inserted AFTER the old row leaves the partial * unique index, hence one transaction, not two calls. */ requeueWorkOrder(wo: WorkOrderRecord, reason: string): WorkOrderRecord; completeWorkOrder(id: number): void; failWorkOrder(id: number, reason: string): void; countPendingWorkOrders(): number; countOpenWorkOrders(kind?: WorkOrderKind): number; findTemporalGenerationKeys(generationKeys: readonly string[]): Set; /** In-progress system rows at boot = crash artifacts (single serial consumer). */ listStaleClaims(): WorkOrderRecord[]; /** * Boot cleanup (plan D3 + review N4): open system rows -> cancelled. A * rollback is not a failure - excluded from failed counters/alarms; caller * logs ONE summary line with the returned count. `onlyKinds` scopes the * cancellation. */ cancelOpenWorkOrders(reason: string, onlyKinds?: WorkOrderKind[]): number; /** Per-kind stats for the workorder_status surface. */ workOrderStats(): Array<{ workKind: WorkOrderKind; lastRunAt: number | null; lastStatus: TaskStatus | null; failedCount: number; lastFailureReason: string | null; }>; private getWorkOrderById; private transitionWorkOrder; private rowToWorkOrder; /** contract_no_update: silence as a verifiable judgment, scoped to one reconcile run. */ recordNoUpdate(scope: string, reason: string): { id: number; }; private insertNoUpdateNote; /** Max no-update note id, optionally scoped - the verifier's note snapshot. */ maxNoUpdateId(scope?: string): number; } //# sourceMappingURL=task-ledger.d.ts.map