@danypops/papyrus 0.41.0 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +8 -11
  2. package/package.json +2 -2
  3. package/src/adapters/sqlite-artifact-scope-store.ts +18 -9
  4. package/src/adapters/sqlite-artifact-store.ts +7 -5
  5. package/src/adapters/sqlite-discussion-round-store.ts +26 -17
  6. package/src/adapters/sqlite-gate-runner.ts +1 -1
  7. package/src/adapters/sqlite-graph-projection-store.ts +14 -10
  8. package/src/adapters/sqlite-log-store.ts +36 -17
  9. package/src/adapters/sqlite-note-event-store.ts +20 -16
  10. package/src/adapters/sqlite-session-identity-store.ts +13 -7
  11. package/src/adapters/sqlite-task-event-store.ts +29 -21
  12. package/src/adapters/sqlite-task-focus-store.ts +36 -10
  13. package/src/adapters/sqlite-task-lease-store.ts +12 -6
  14. package/src/adapters/sqlite-task-scope-store.ts +25 -14
  15. package/src/artifact-relationship-view.ts +4 -4
  16. package/src/artifact-subtree.ts +4 -2
  17. package/src/authority-registry.ts +2 -1
  18. package/src/cli.ts +794 -354
  19. package/src/client.ts +6 -3
  20. package/src/constants.ts +34 -56
  21. package/src/daemon-state.ts +4 -12
  22. package/src/daemon.ts +31 -9
  23. package/src/db.ts +153 -105
  24. package/src/discussion-service.ts +109 -44
  25. package/src/domain/artifact-event.ts +18 -5
  26. package/src/domain/artifact.ts +3 -1
  27. package/src/domain/blueprint-definition.ts +268 -0
  28. package/src/domain/checklist.ts +20 -17
  29. package/src/domain/discussion.ts +37 -18
  30. package/src/domain/gate.ts +7 -7
  31. package/src/domain/log-entry.ts +1 -1
  32. package/src/domain/note-event.ts +20 -7
  33. package/src/domain/task-event.ts +17 -7
  34. package/src/domain-services.ts +362 -288
  35. package/src/graph-projection-service.ts +34 -8
  36. package/src/id-migration.ts +17 -4
  37. package/src/index.ts +16 -11
  38. package/src/log-service.ts +6 -5
  39. package/src/log.ts +19 -0
  40. package/src/modules/discuss.ts +63 -28
  41. package/src/modules/docs.ts +74 -17
  42. package/src/modules/graph-projection.ts +20 -9
  43. package/src/modules/logs.ts +34 -22
  44. package/src/modules/notes.ts +66 -28
  45. package/src/modules/playbooks.ts +93 -32
  46. package/src/modules/rules.ts +57 -15
  47. package/src/modules/session-identity.ts +6 -2
  48. package/src/modules/tasks.ts +142 -67
  49. package/src/note-service.ts +11 -7
  50. package/src/ops.ts +134 -69
  51. package/src/playbook-definition.ts +124 -39
  52. package/src/playbook-execution.ts +18 -25
  53. package/src/ports/artifact-scope-store.ts +1 -1
  54. package/src/ports/note-event-store.ts +6 -4
  55. package/src/ports/task-event-store.ts +9 -6
  56. package/src/ports/task-focus-store.ts +17 -4
  57. package/src/ports/task-lease-store.ts +9 -4
  58. package/src/ports/task-scope-store.ts +3 -1
  59. package/src/service.ts +148 -110
  60. package/src/session-identity-service.ts +10 -2
  61. package/src/task-context.ts +28 -16
  62. package/src/task-execution.ts +4 -12
  63. package/src/task-graph-view.ts +12 -12
  64. package/src/task-relationship-view.ts +1 -3
  65. package/src/task-service.ts +168 -73
  66. package/src/vehicle/artifact-trash-vehicle.ts +26 -14
  67. package/src/vehicle/artifact-vehicle-shared.ts +32 -13
  68. package/src/vehicle/docs-vehicle.ts +50 -18
  69. package/src/vehicle/notes-vehicle.ts +26 -8
  70. package/src/vehicle/papyrus-vehicle.ts +16 -8
  71. package/src/vehicle/playbooks-vehicle.ts +88 -19
  72. package/src/vehicle/rules-vehicle.ts +58 -21
  73. package/src/vehicle/tasks-vehicle.ts +366 -54
  74. package/src/version.ts +1 -1
  75. package/src/workflow-execution.ts +198 -109
  76. package/src/domain/skill-definition.ts +0 -270
  77. package/src/modules/skills.ts +0 -158
  78. package/src/vehicle/skills-vehicle.ts +0 -194
package/README.md CHANGED
@@ -42,7 +42,7 @@ The daemon uses WAL, foreign keys, a bounded busy timeout, versioned migrations,
42
42
 
43
43
  `artifacts` is the shared graph-identity supertype, not a second copy of every application's database. `edges` references that single identity table at both endpoints, preserving foreign-key integrity for cross-domain links. Domain extension tables exist only where application invariants require indexed relational state: Task chronology/focus/scope and Discourse posts/events/session cursors/projection checkpoints. This is a class-table/table-per-type variant with explicit child-to-parent foreign keys; Papyrus does not use SQLite table inheritance or orphan-prone `(target_type, target_id)` links.
44
44
 
45
- The owning application remains the mutation authority. Discourse commits its extension rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`; generic artifact, document, Skill-template, lifecycle, and graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers additionally verify that each extension row references the expected Doc subtype. Domain tables are canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
45
+ The owning application remains the mutation authority. Discourse commits its extension rows and `context-thread`/`context-message` Doc projections atomically through `discourse.store`; generic artifact, document, lifecycle, and graph-link operations reject those owned subtypes and the `reply_to`/`discusses` relations. SQLite triggers additionally verify that each extension row references the expected Doc subtype. Domain tables are canonical for domain invariants; graph bodies and metadata are read-oriented projections committed in the same transaction.
46
46
 
47
47
  The authenticated CLI exposes the same operation for diagnostics and adapter parity:
48
48
 
@@ -59,7 +59,7 @@ Papyrus enforces four artifact kinds:
59
59
  - `doc` — knowledge: specifications, decisions, and research
60
60
  - `task` — work: desired outcomes, gates, checklists, and dependencies
61
61
  - `rule` — governance injected into the Pi system prompt
62
- - `skill` — a parameterized workflow bundle whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
62
+ - `playbook` — a trigger and an ordered list of steps whose validated arguments render a connected collection of deterministic Tasks plus contextual Rules and Docs
63
63
 
64
64
  Each kind has an enforced status vocabulary. Every edge endpoint must exist, and every edge relation must be registered in `relation_names`. Relations are universal: any artifact kind can link to any other kind.
65
65
 
@@ -67,21 +67,18 @@ Each kind has an enforced status vocabulary. Every edge endpoint must exist, and
67
67
 
68
68
  Use `contains` and `part_of` for explicit parent/child structure; use `depends_on` for execution ordering. Dependency edges form an executable DAG: self-dependencies and cycles are rejected, fan-in waits for every prerequisite, and fan-out can expose several ready successors while active focus remains singular. Graph reads are cycle-safe and bounded by `depth` and `max_nodes` (defaults: depth 4, 100 nodes; hard ceilings: depth 20, 1,000 nodes). Executable task plans are additionally bounded to 1,000 tasks and 10,000 relationships.
69
69
 
70
- ### Skills and compatibility templates
70
+ ### Playbooks
71
71
 
72
- A Papyrus Skill is distinct from a conventional prompt-only skill: its input API and blueprints define a connected Task/Rule/Doc workflow. `skills.run` validates and normalizes all arguments, safely renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Task dependencies, containment, gates, checklists, and context survive rendering. Run Rules are injected only while active focus belongs to that run. Docs retain invocation context and provenance; missing evidence references remain unknown and no gate runs during instantiation.
72
+ A Playbook's steps are a plain prose string (a Task), or a structured object: `{kind:'doc',...}` creates a Doc, `{kind:'rule',...}` creates a Rule, `{kind:'call',...}` nests another Playbook's own run as a pipeline step. `playbooks.invoke` validates and normalizes all arguments, safely renders placeholders in memory, validates the complete graph, then persists artifacts and edges in one transaction. Task dependencies, containment, gates, checklists, and context survive rendering. Run Rules are injected only while active focus belongs to that run. Docs retain invocation context and provenance; missing evidence references remain unknown and no gate runs during instantiation.
73
73
 
74
- A run result has a stable schema: Skill ID, run ID, normalized arguments, created IDs grouped by kind, ready root task IDs, and the bounded execution plan. Explicit run IDs produce deterministic artifact IDs (`<run-id>-<blueprint-ref>`); collisions roll back the entire run.
74
+ A run result has a stable schema: Playbook ID, run ID, normalized arguments, created IDs grouped by kind, ready root task IDs, and the bounded execution plan. Explicit run IDs produce deterministic artifact IDs (`<run-id>-<blueprint-ref>`); collisions roll back the entire run.
75
75
 
76
76
  ```bash
77
- papyrus skills run <skill-id> \
77
+ papyrus playbooks invoke <playbook-id> \
78
78
  --arguments-json '{"project":"Papyrus"}' \
79
- --run-id papyrus-001 \
80
79
  --json
81
80
  ```
82
81
 
83
- The existing `artifact-template` skill subtype remains a compatibility mechanism for one-artifact templates with metadata `{targetKind, defaults, required}`. Instantiate it via the `skills.instantiate` operation with `template_id`; defaults merge recursively, explicit arrays replace defaults, required paths such as `extra.owner` are validated, and target-kind mismatches are rejected.
84
-
85
82
  ### Removing an artifact
86
83
 
87
84
  Artifacts are never hard-deleted on request: every artifact gets a permanent, immutable `created` row in the mutation event log the moment it exists, so removal is a real, time-gated trash rather than a status flip. `remove` (the shared `artifact.remove`/`artifact.remove_subtree` operations every agent-facing domain routes through, or `papyrus artifact remove <id> [--reason <text>]`) moves an artifact to the trash: it is immediately excluded from every list/query, still directly reachable by id, and fully recoverable via `restore` until its purge deadline (30 days later) passes. `remove` refuses a Task that is the live Task Focus in any scope.
@@ -90,11 +87,11 @@ Once the deadline passes, the daemon's periodic sweep performs a real, cascading
90
87
 
91
88
  ### Naming vs. ids
92
89
 
93
- Every agent domain tool (tasks, docs, rules, skills, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), `template_name` (skills instantiate), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
90
+ Every agent domain tool (tasks, docs, rules, playbooks, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
94
91
 
95
92
  ### Mutability
96
93
 
97
- Tasks, Docs, Rules, and Skills all support first-class `update` (title/body/labels, at least one required) alongside creation -- a Doc's body is no longer immutable once created. Every update is bounded the same way creation is (Rules keep their own stricter combined condition+action+body ceiling; Docs/Skills share Tasks' own length bounds) and recorded on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider` on an ingested page) is a read-only projection from a system Papyrus doesn't own the source of; updating one is refused with a clear error rather than silently forking it -- capture a correction as a new linked Doc instead until a write-back capability to that system exists. Notes stay behind their own facade for any content change, same as every other Notes mutation.
94
+ Tasks, Docs, Rules, and Playbooks all support first-class `update` (title/body/labels, at least one required) alongside creation -- a Doc's body is no longer immutable once created. Every update is bounded the same way creation is (Rules keep their own stricter combined condition+action+body ceiling; Docs/Playbooks share Tasks' own length bounds) and recorded on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider` on an ingested page) is a read-only projection from a system Papyrus doesn't own the source of; updating one is refused with a clear error rather than silently forking it -- capture a correction as a new linked Doc instead until a write-back capability to that system exists. Notes stay behind their own facade for any content change, same as every other Notes mutation.
98
95
 
99
96
  Internally, application services depend on the `ArtifactStore` and `GateRunner` ports. SQLite and subprocess execution are adapters composed only by the daemon; task behavior is unit-tested against fakes without a database. Task visualization projects the same `TaskGraph` into semantic display graphs and sends them through a `GraphRenderer` port -- the Pi adapter (in `@danypops/pi-papyrus`) uses `beautiful-mermaid` for terminal Unicode output without leaking Mermaid syntax into the task domain.
100
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.41.0",
3
+ "version": "0.42.1",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -36,6 +36,6 @@
36
36
  "dependencies": {
37
37
  "@danypops/vehicle-core": "^0.2.0",
38
38
  "@danypops/vehicle-client": "^0.1.1",
39
- "@danypops/vehicle-server": "^0.3.2"
39
+ "@danypops/vehicle-server": "^0.4.1"
40
40
  }
41
41
  }
@@ -8,29 +8,38 @@ export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
8
8
 
9
9
  assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): ArtifactScope {
10
10
  inTransaction(this.db, () => {
11
- this.db.prepare(`
11
+ this.db
12
+ .prepare(`
12
13
  INSERT INTO artifact_scopes (artifact_id, project_root, source, assigned_at)
13
14
  VALUES (?, ?, ?, ?)
14
15
  ON CONFLICT(artifact_id) DO UPDATE SET
15
16
  project_root = excluded.project_root,
16
17
  source = excluded.source,
17
18
  assigned_at = excluded.assigned_at
18
- `).run(artifactId, projectRoot ?? null, source, new Date().toISOString());
19
+ `)
20
+ .run(artifactId, projectRoot ?? null, source, new Date().toISOString());
19
21
  });
20
22
  return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
21
23
  }
22
24
 
23
25
  get(artifactId: string): ArtifactScope | undefined {
24
- const row = this.db.prepare("SELECT artifact_id, project_root, source FROM artifact_scopes WHERE artifact_id = ?").get(artifactId) as
25
- | { artifact_id: string; project_root: string | null; source: TaskScopeSource }
26
- | null;
27
- return row ? { artifactId: row.artifact_id, ...(row.project_root === null ? {} : { projectRoot: row.project_root }), source: row.source } : undefined;
26
+ const row = this.db.prepare("SELECT artifact_id, project_root, source FROM artifact_scopes WHERE artifact_id = ?").get(artifactId) as {
27
+ artifact_id: string;
28
+ project_root: string | null;
29
+ source: TaskScopeSource;
30
+ } | null;
31
+ return row
32
+ ? { artifactId: row.artifact_id, ...(row.project_root === null ? {} : { projectRoot: row.project_root }), source: row.source }
33
+ : undefined;
28
34
  }
29
35
 
30
36
  ids(projectRoot: string | undefined, limit: number): string[] {
31
- const rows = projectRoot === undefined
32
- ? this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root IS NULL ORDER BY artifact_id LIMIT ?").all(limit)
33
- : this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root = ? ORDER BY artifact_id LIMIT ?").all(projectRoot, limit);
37
+ const rows =
38
+ projectRoot === undefined
39
+ ? this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root IS NULL ORDER BY artifact_id LIMIT ?").all(limit)
40
+ : this.db
41
+ .prepare("SELECT artifact_id FROM artifact_scopes WHERE project_root = ? ORDER BY artifact_id LIMIT ?")
42
+ .all(projectRoot, limit);
34
43
  return (rows as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
35
44
  }
36
45
  }
@@ -1,8 +1,5 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import { inTransaction } from "../db.ts";
3
- import type { AtomicArtifactStore } from "../ports/atomic-artifact-store.ts";
4
- import type { ArtifactEventReader } from "../ports/artifact-event-reader.ts";
5
- import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
6
3
  import type {
7
4
  Artifact,
8
5
  ArtifactEdge,
@@ -31,6 +28,9 @@ import {
31
28
  updateExtra,
32
29
  updateStatus,
33
30
  } from "../ops.ts";
31
+ import type { ArtifactEventReader } from "../ports/artifact-event-reader.ts";
32
+ import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
33
+ import type { AtomicArtifactStore } from "../ports/atomic-artifact-store.ts";
34
34
 
35
35
  export class SQLiteArtifactStore implements AtomicArtifactStore, ArtifactTrashStore, ArtifactEventReader {
36
36
  constructor(private readonly db: Db) {}
@@ -115,7 +115,8 @@ export class SQLiteArtifactStore implements AtomicArtifactStore, ArtifactTrashSt
115
115
  limit = "LIMIT ?";
116
116
  parameters.push(filter.limit);
117
117
  }
118
- return this.db.prepare(`
118
+ return this.db
119
+ .prepare(`
119
120
  SELECT edges.from_id AS "from", edges.relation, edges.to_id AS "to"
120
121
  FROM edges
121
122
  JOIN artifacts AS source ON source.id = edges.from_id
@@ -123,6 +124,7 @@ export class SQLiteArtifactStore implements AtomicArtifactStore, ArtifactTrashSt
123
124
  ${where}
124
125
  ORDER BY edges.rowid
125
126
  ${limit}
126
- `).all(...parameters) as ArtifactEdge[];
127
+ `)
128
+ .all(...parameters) as ArtifactEdge[];
127
129
  }
128
130
  }
@@ -1,13 +1,13 @@
1
- import type { Db } from "../db.ts";
2
1
  import { DISCUSSION_ROUNDS_DEFAULT_LIMIT, DISCUSSION_ROUNDS_MAX_LIMIT } from "../constants.ts";
2
+ import type { Db } from "../db.ts";
3
3
  import {
4
- validateDiscussionActor,
5
- validateDiscussionContent,
6
- validateDiscussionOptions,
7
4
  type AppendDiscussionRound,
8
5
  type DiscussionOptionsMode,
9
6
  type DiscussionRound,
10
7
  type DiscussionRoundQuery,
8
+ validateDiscussionActor,
9
+ validateDiscussionContent,
10
+ validateDiscussionOptions,
11
11
  } from "../domain/discussion.ts";
12
12
  import type { DiscussionRoundStore } from "../ports/discussion-round-store.ts";
13
13
 
@@ -48,19 +48,26 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
48
48
  // selected isn't validated here -- it requires cross-referencing the Discussion's
49
49
  // currently pending options (extra.discussion), which this store, deliberately scoped to
50
50
  // the rounds table alone, has no access to. discussion-service.ts validates it beforehand.
51
- const posed = round.options !== undefined || round.optionsMode !== undefined
52
- ? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
53
- : undefined;
54
- const result = this.db.prepare(`
51
+ const posed =
52
+ round.options !== undefined || round.optionsMode !== undefined
53
+ ? validateDiscussionOptions(round.options ?? [], round.optionsMode ?? "", round.optionDescriptions)
54
+ : undefined;
55
+ const result = this.db
56
+ .prepare(`
55
57
  INSERT INTO discussion_rounds (discussion_id, round_number, actor, content, occurred_at, event_schema_version, options, options_mode, selected, option_descriptions)
56
58
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
57
- `).run(
58
- round.discussionId, round.roundNumber, actor, content, occurredAt,
59
- posed ? JSON.stringify(posed.options) : null,
60
- posed ? posed.mode : null,
61
- round.selected !== undefined ? JSON.stringify(round.selected) : null,
62
- posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
63
- );
59
+ `)
60
+ .run(
61
+ round.discussionId,
62
+ round.roundNumber,
63
+ actor,
64
+ content,
65
+ occurredAt,
66
+ posed ? JSON.stringify(posed.options) : null,
67
+ posed ? posed.mode : null,
68
+ round.selected !== undefined ? JSON.stringify(round.selected) : null,
69
+ posed?.optionDescriptions ? JSON.stringify(posed.optionDescriptions) : null,
70
+ );
64
71
  return {
65
72
  id: Number(result.lastInsertRowid),
66
73
  discussionId: round.discussionId,
@@ -76,13 +83,15 @@ export class SQLiteDiscussionRoundStore implements DiscussionRoundStore {
76
83
 
77
84
  list(query: DiscussionRoundQuery): DiscussionRound[] {
78
85
  const limit = Math.min(DISCUSSION_ROUNDS_MAX_LIMIT, Math.max(1, Math.floor(query.limit ?? DISCUSSION_ROUNDS_DEFAULT_LIMIT)));
79
- const rows = this.db.prepare(`
86
+ const rows = this.db
87
+ .prepare(`
80
88
  SELECT id, discussion_id, round_number, actor, content, occurred_at, options, options_mode, selected, option_descriptions
81
89
  FROM discussion_rounds
82
90
  WHERE discussion_id = ? AND round_number > ?
83
91
  ORDER BY round_number ASC
84
92
  LIMIT ?
85
- `).all(query.discussionId, query.afterRound ?? 0, limit) as DiscussionRoundRow[];
93
+ `)
94
+ .all(query.discussionId, query.afterRound ?? 0, limit) as DiscussionRoundRow[];
86
95
  return rows.map(mapRow);
87
96
  }
88
97
 
@@ -1,7 +1,7 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import type { GateResult, GateRunOptions } from "../domain/gate.ts";
3
- import type { GateRunner } from "../ports/gate-runner.ts";
4
3
  import { runGates, runGatesAsync } from "../ops.ts";
4
+ import type { GateRunner } from "../ports/gate-runner.ts";
5
5
 
6
6
  export class SQLiteGateRunner implements GateRunner {
7
7
  constructor(private readonly db: Db) {}
@@ -7,35 +7,39 @@ export class SQLiteGraphProjectionStore implements GraphProjectionStore {
7
7
  constructor(private readonly db: Db) {}
8
8
 
9
9
  getCheckpoint(producerId: string): ProjectionCheckpoint | null {
10
- const row = this.db.prepare("SELECT producer_id, last_sequence, last_batch_id, applied_at FROM graph_projection_checkpoints WHERE producer_id = ?").get(producerId) as
11
- | { producer_id: string; last_sequence: number; last_batch_id: string; applied_at: string }
12
- | null;
10
+ const row = this.db
11
+ .prepare("SELECT producer_id, last_sequence, last_batch_id, applied_at FROM graph_projection_checkpoints WHERE producer_id = ?")
12
+ .get(producerId) as { producer_id: string; last_sequence: number; last_batch_id: string; applied_at: string } | null;
13
13
  if (row == null) return null;
14
14
  return { producerId: row.producer_id, lastSequence: row.last_sequence, lastBatchId: row.last_batch_id, appliedAt: row.applied_at };
15
15
  }
16
16
 
17
17
  resolveIdentity(producerId: string, externalId: string): string | undefined {
18
- const row = this.db.prepare("SELECT artifact_id FROM graph_projection_identities WHERE producer_id = ? AND external_id = ?").get(producerId, externalId) as
19
- | { artifact_id: string }
20
- | null;
18
+ const row = this.db
19
+ .prepare("SELECT artifact_id FROM graph_projection_identities WHERE producer_id = ? AND external_id = ?")
20
+ .get(producerId, externalId) as { artifact_id: string } | null;
21
21
  return row?.artifact_id;
22
22
  }
23
23
 
24
24
  recordIdentity(producerId: string, externalId: string, artifactId: string): void {
25
25
  inTransaction(this.db, () => {
26
- this.db.prepare(`
26
+ this.db
27
+ .prepare(`
27
28
  INSERT INTO graph_projection_identities (producer_id, external_id, artifact_id) VALUES (?, ?, ?)
28
29
  ON CONFLICT(producer_id, external_id) DO UPDATE SET artifact_id = excluded.artifact_id
29
- `).run(producerId, externalId, artifactId);
30
+ `)
31
+ .run(producerId, externalId, artifactId);
30
32
  });
31
33
  }
32
34
 
33
35
  commitCheckpoint(checkpoint: ProjectionCheckpoint): void {
34
36
  inTransaction(this.db, () => {
35
- this.db.prepare(`
37
+ this.db
38
+ .prepare(`
36
39
  INSERT INTO graph_projection_checkpoints (producer_id, last_sequence, last_batch_id, applied_at) VALUES (?, ?, ?, ?)
37
40
  ON CONFLICT(producer_id) DO UPDATE SET last_sequence = excluded.last_sequence, last_batch_id = excluded.last_batch_id, applied_at = excluded.applied_at
38
- `).run(checkpoint.producerId, checkpoint.lastSequence, checkpoint.lastBatchId, checkpoint.appliedAt);
41
+ `)
42
+ .run(checkpoint.producerId, checkpoint.lastSequence, checkpoint.lastBatchId, checkpoint.appliedAt);
39
43
  });
40
44
  }
41
45
  }
@@ -43,34 +43,51 @@ export class SQLiteLogStore implements LogStore {
43
43
  constructor(private readonly db: Db) {}
44
44
 
45
45
  ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource {
46
- const existing = this.db.prepare("SELECT id, label, project_root, created_at FROM log_sources WHERE id = ?").get(sourceId) as SourceRow | undefined;
46
+ const existing = this.db.prepare("SELECT id, label, project_root, created_at FROM log_sources WHERE id = ?").get(sourceId) as
47
+ | SourceRow
48
+ | undefined;
47
49
  if (existing) return toSource(existing);
48
50
  const createdAt = new Date().toISOString();
49
- this.db.prepare("INSERT INTO log_sources (id, label, project_root, created_at) VALUES (?, ?, ?, ?)").run(sourceId, label, projectRoot, createdAt);
51
+ this.db
52
+ .prepare("INSERT INTO log_sources (id, label, project_root, created_at) VALUES (?, ?, ?, ?)")
53
+ .run(sourceId, label, projectRoot, createdAt);
50
54
  return { id: sourceId, label, projectRoot, createdAt };
51
55
  }
52
56
 
53
57
  findEntryByOperationId(sourceId: string, operationId: string): LogEntry | undefined {
54
- const row = this.db.prepare(
55
- "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? AND operation_id = ?",
56
- ).get(sourceId, operationId) as EntryRow | undefined;
58
+ const row = this.db
59
+ .prepare(
60
+ "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? AND operation_id = ?",
61
+ )
62
+ .get(sourceId, operationId) as EntryRow | undefined;
57
63
  return row ? toEntry(row) : undefined;
58
64
  }
59
65
 
60
66
  insertEntry(entry: LogEntry): void {
61
- this.db.prepare(
62
- `INSERT INTO log_entries (id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id)
67
+ this.db
68
+ .prepare(
69
+ `INSERT INTO log_entries (id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id)
63
70
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
64
- ).run(
65
- entry.id, entry.sourceId, entry.occurredAt, entry.level, entry.message,
66
- entry.truncated ? 1 : 0, JSON.stringify(entry.fields), entry.operationId, entry.sessionId ?? null,
67
- );
71
+ )
72
+ .run(
73
+ entry.id,
74
+ entry.sourceId,
75
+ entry.occurredAt,
76
+ entry.level,
77
+ entry.message,
78
+ entry.truncated ? 1 : 0,
79
+ JSON.stringify(entry.fields),
80
+ entry.operationId,
81
+ entry.sessionId ?? null,
82
+ );
68
83
  }
69
84
 
70
85
  entriesForSource(sourceId: string): readonly LogEntry[] {
71
- const rows = this.db.prepare(
72
- "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? ORDER BY occurred_at, id",
73
- ).all(sourceId) as EntryRow[];
86
+ const rows = this.db
87
+ .prepare(
88
+ "SELECT id, source_id, occurred_at, level, message, truncated, fields_json, operation_id, session_id FROM log_entries WHERE source_id = ? ORDER BY occurred_at, id",
89
+ )
90
+ .all(sourceId) as EntryRow[];
74
91
  return rows.map(toEntry);
75
92
  }
76
93
 
@@ -78,11 +95,13 @@ export class SQLiteLogStore implements LogStore {
78
95
  const countRow = this.db.prepare("SELECT COUNT(*) as count FROM log_entries WHERE source_id = ?").get(sourceId) as { count: number };
79
96
  const excess = countRow.count - maxEntries;
80
97
  if (excess <= 0) return 0;
81
- this.db.prepare(
82
- `DELETE FROM log_entries WHERE id IN (
98
+ this.db
99
+ .prepare(
100
+ `DELETE FROM log_entries WHERE id IN (
83
101
  SELECT id FROM log_entries WHERE source_id = ? ORDER BY occurred_at, id LIMIT ?
84
102
  )`,
85
- ).run(sourceId, excess);
103
+ )
104
+ .run(sourceId, excess);
86
105
  return excess;
87
106
  }
88
107
  }
@@ -1,12 +1,12 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import {
3
- normalizeNoteHistoryQuery,
4
- validateNoteEvent,
5
3
  type AppendNoteEvent,
6
4
  type NoteEvent,
7
5
  type NoteEventType,
8
6
  type NoteHistoryPage,
9
7
  type NoteHistoryQuery,
8
+ normalizeNoteHistoryQuery,
9
+ validateNoteEvent,
10
10
  } from "../domain/note-event.ts";
11
11
  import type { NoteEventStore } from "../ports/note-event-store.ts";
12
12
 
@@ -45,21 +45,23 @@ export class SQLiteNoteEventStore implements NoteEventStore {
45
45
 
46
46
  append(input: AppendNoteEvent): NoteEvent {
47
47
  const event = validateNoteEvent(input);
48
- const result = this.db.prepare(`
48
+ const result = this.db
49
+ .prepare(`
49
50
  INSERT INTO note_events (
50
51
  note_id, occurred_at, event_type, actor, source, session_id, reason, related_id, disposition, event_schema_version
51
52
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
52
- `).run(
53
- event.noteId,
54
- new Date().toISOString(),
55
- event.type,
56
- event.actor,
57
- event.source,
58
- event.sessionId ?? null,
59
- event.reason ?? null,
60
- event.relatedId ?? null,
61
- event.disposition ?? null,
62
- );
53
+ `)
54
+ .run(
55
+ event.noteId,
56
+ new Date().toISOString(),
57
+ event.type,
58
+ event.actor,
59
+ event.source,
60
+ event.sessionId ?? null,
61
+ event.reason ?? null,
62
+ event.relatedId ?? null,
63
+ event.disposition ?? null,
64
+ );
63
65
  return mapRow(this.db.prepare("SELECT * FROM note_events WHERE id = ?").get(result.lastInsertRowid) as NoteEventRow);
64
66
  }
65
67
 
@@ -67,12 +69,14 @@ export class SQLiteNoteEventStore implements NoteEventStore {
67
69
  const { limit, direction, cursor } = normalizeNoteHistoryQuery(query);
68
70
  const comparator = direction === "desc" ? "<" : ">";
69
71
  const order = direction === "desc" ? "DESC" : "ASC";
70
- const rows = this.db.prepare(`
72
+ const rows = this.db
73
+ .prepare(`
71
74
  SELECT * FROM note_events
72
75
  WHERE note_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
73
76
  ORDER BY occurred_at ${order}, id ${order}
74
77
  LIMIT ?
75
- `).all(...(cursor === undefined ? [noteId, limit + 1] : [noteId, cursor, limit + 1])) as NoteEventRow[];
78
+ `)
79
+ .all(...(cursor === undefined ? [noteId, limit + 1] : [noteId, cursor, limit + 1])) as NoteEventRow[];
76
80
  const hasMore = rows.length > limit;
77
81
  const events = rows.slice(0, limit).map(mapRow);
78
82
  return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
@@ -7,20 +7,24 @@ export class SQLiteSessionIdentityStore implements SessionIdentityStore {
7
7
  constructor(private readonly db: Db) {}
8
8
 
9
9
  find(sessionId: string): SessionIdentityRecord | undefined {
10
- const row = this.db.prepare("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?").get(sessionId) as
11
- | { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string }
12
- | null;
13
- return row ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at } : undefined;
10
+ const row = this.db
11
+ .prepare("SELECT session_id, secret_hash, registered_at, last_seen_at FROM session_identities WHERE session_id = ?")
12
+ .get(sessionId) as { session_id: string; secret_hash: string; registered_at: string; last_seen_at: string } | null;
13
+ return row
14
+ ? { sessionId: row.session_id, secretHash: row.secret_hash, registeredAt: row.registered_at, lastSeenAt: row.last_seen_at }
15
+ : undefined;
14
16
  }
15
17
 
16
18
  upsert(record: SessionIdentityRecord): void {
17
19
  inTransaction(this.db, () => {
18
20
  this.evictOldestBeyondCap(record.sessionId);
19
- this.db.prepare(`
21
+ this.db
22
+ .prepare(`
20
23
  INSERT INTO session_identities (session_id, secret_hash, registered_at, last_seen_at)
21
24
  VALUES (?, ?, ?, ?)
22
25
  ON CONFLICT(session_id) DO UPDATE SET secret_hash = excluded.secret_hash, registered_at = excluded.registered_at, last_seen_at = excluded.last_seen_at
23
- `).run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
26
+ `)
27
+ .run(record.sessionId, record.secretHash, record.registeredAt, record.lastSeenAt);
24
28
  });
25
29
  }
26
30
 
@@ -41,6 +45,8 @@ export class SQLiteSessionIdentityStore implements SessionIdentityStore {
41
45
  const exists = this.db.prepare("SELECT 1 FROM session_identities WHERE session_id = ?").get(sessionId);
42
46
  if (exists) return;
43
47
  if (this.count() < SESSION_IDENTITY_MAX_ROWS) return;
44
- this.db.exec("DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)");
48
+ this.db.exec(
49
+ "DELETE FROM session_identities WHERE session_id = (SELECT session_id FROM session_identities ORDER BY last_seen_at ASC LIMIT 1)",
50
+ );
45
51
  }
46
52
  }
@@ -1,10 +1,9 @@
1
1
  import type { Db } from "../db.ts";
2
2
  import { inTransaction } from "../db.ts";
3
3
  import {
4
+ type AppendTaskEvent,
4
5
  normalizeTaskEventFeedQuery,
5
6
  normalizeTaskHistoryQuery,
6
- validateTaskEvent,
7
- type AppendTaskEvent,
8
7
  type TaskEvent,
9
8
  type TaskEventEvidence,
10
9
  type TaskEventFeedPage,
@@ -13,6 +12,7 @@ import {
13
12
  type TaskHistoryPage,
14
13
  type TaskHistoryQuery,
15
14
  type TaskLifecycleStatus,
15
+ validateTaskEvent,
16
16
  } from "../domain/task-event.ts";
17
17
  import type { TaskEventStore } from "../ports/task-event-store.ts";
18
18
 
@@ -53,28 +53,32 @@ function mapRow(row: TaskEventRow): TaskEvent {
53
53
  export class SQLiteTaskEventStore implements TaskEventStore {
54
54
  constructor(private readonly db: Db) {}
55
55
 
56
- atomic<T>(operation: () => T): T { return inTransaction(this.db, operation); }
56
+ atomic<T>(operation: () => T): T {
57
+ return inTransaction(this.db, operation);
58
+ }
57
59
 
58
60
  append(input: AppendTaskEvent): TaskEvent {
59
61
  const event = validateTaskEvent(input);
60
- const result = this.db.prepare(`
62
+ const result = this.db
63
+ .prepare(`
61
64
  INSERT INTO task_events (
62
65
  task_id, occurred_at, event_type, actor, source, session_id, reason,
63
66
  from_status, to_status, attempt_id, evidence_json, event_schema_version
64
67
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
65
- `).run(
66
- event.taskId,
67
- new Date().toISOString(),
68
- event.type,
69
- event.actor,
70
- event.source,
71
- event.sessionId ?? null,
72
- event.reason ?? null,
73
- event.fromStatus ?? null,
74
- event.toStatus ?? null,
75
- event.attemptId ?? null,
76
- event.evidence === undefined ? null : JSON.stringify(event.evidence),
77
- );
68
+ `)
69
+ .run(
70
+ event.taskId,
71
+ new Date().toISOString(),
72
+ event.type,
73
+ event.actor,
74
+ event.source,
75
+ event.sessionId ?? null,
76
+ event.reason ?? null,
77
+ event.fromStatus ?? null,
78
+ event.toStatus ?? null,
79
+ event.attemptId ?? null,
80
+ event.evidence === undefined ? null : JSON.stringify(event.evidence),
81
+ );
78
82
  return mapRow(this.db.prepare("SELECT * FROM task_events WHERE id = ?").get(result.lastInsertRowid) as TaskEventRow);
79
83
  }
80
84
 
@@ -82,12 +86,14 @@ export class SQLiteTaskEventStore implements TaskEventStore {
82
86
  const { limit, direction, cursor } = normalizeTaskHistoryQuery(query);
83
87
  const comparator = direction === "desc" ? "<" : ">";
84
88
  const order = direction === "desc" ? "DESC" : "ASC";
85
- const rows = this.db.prepare(`
89
+ const rows = this.db
90
+ .prepare(`
86
91
  SELECT * FROM task_events
87
92
  WHERE task_id = ? ${cursor === undefined ? "" : `AND id ${comparator} ?`}
88
93
  ORDER BY occurred_at ${order}, id ${order}
89
94
  LIMIT ?
90
- `).all(...(cursor === undefined ? [taskId, limit + 1] : [taskId, cursor, limit + 1])) as TaskEventRow[];
95
+ `)
96
+ .all(...(cursor === undefined ? [taskId, limit + 1] : [taskId, cursor, limit + 1])) as TaskEventRow[];
91
97
  const hasMore = rows.length > limit;
92
98
  const events = rows.slice(0, limit).map(mapRow);
93
99
  return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
@@ -100,12 +106,14 @@ export class SQLiteTaskEventStore implements TaskEventStore {
100
106
  if (cursor !== undefined) params.push(cursor);
101
107
  if (eventTypes) params.push(...eventTypes);
102
108
  params.push(limit + 1);
103
- const rows = this.db.prepare(`
109
+ const rows = this.db
110
+ .prepare(`
104
111
  SELECT * FROM task_events
105
112
  WHERE 1=1 ${cursor === undefined ? "" : "AND id > ?"} ${typeFilter}
106
113
  ORDER BY id ASC
107
114
  LIMIT ?
108
- `).all(...params) as TaskEventRow[];
115
+ `)
116
+ .all(...params) as TaskEventRow[];
109
117
  const hasMore = rows.length > limit;
110
118
  const events = rows.slice(0, limit).map(mapRow);
111
119
  return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };