@danypops/papyrus 0.45.2 → 0.46.0

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Rule domain composition logic (create/list/show/preview/transition/update/gate), split out
3
+ * of the former domain-services.ts into its own per-domain file alongside
4
+ * docs/docs-service.ts and playbook/playbook-service.ts. Shared, kind-agnostic helpers live
5
+ * in ../domain-service-shared.ts.
6
+ */
7
+
8
+ import type { Artifact } from "../artifact/artifact.ts";
9
+ import { requireLocallyOwnedContent } from "../artifact/artifact.ts";
10
+ import type { ArtifactEventContext } from "../artifact/artifact-event.ts";
11
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
12
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
13
+ import { RULE_TEXT_HARD_LIMIT_CHARACTERS, RULE_TEXT_SOFT_TARGET_CHARACTERS } from "../constants.ts";
14
+ import { normalizeProjectRoot } from "../domain/task-scope.ts";
15
+ import {
16
+ assertLabelsBounds,
17
+ assertTitleBounds,
18
+ assignArtifactProject,
19
+ type ListFilter,
20
+ listScoped,
21
+ requireContentUpdateFields,
22
+ requireKind,
23
+ runTransition,
24
+ type TransitionTable,
25
+ type UpdateContentInput,
26
+ } from "../domain-service-shared.ts";
27
+
28
+ export interface CreateRuleInput {
29
+ title: string;
30
+ body?: string;
31
+ condition?: string;
32
+ action?: string;
33
+ severity?: "block" | "warn" | "info";
34
+ subtype?: string;
35
+ labels?: string[];
36
+ extra?: Record<string, unknown>;
37
+ templateId?: string;
38
+ projectRoot?: string;
39
+ }
40
+
41
+ export type RuleTransition = "enable" | "disable";
42
+
43
+ const RULE_TRANSITIONS: TransitionTable<RuleTransition, string> = {
44
+ enable: { from: ["draft", "deprecated"], to: "active" },
45
+ disable: { from: ["active"], to: "deprecated" },
46
+ };
47
+
48
+ function valueAtPath(value: unknown, path: string): unknown {
49
+ return path
50
+ .split(".")
51
+ .reduce<unknown>(
52
+ (current, segment) =>
53
+ typeof current === "object" && current !== null && !Array.isArray(current)
54
+ ? (current as Record<string, unknown>)[segment]
55
+ : undefined,
56
+ value,
57
+ );
58
+ }
59
+
60
+ function isPresent(value: unknown): boolean {
61
+ return value !== undefined && value !== null && value !== "";
62
+ }
63
+
64
+ function assertTemplateConformance(artifacts: ArtifactStore, rule: Artifact): void {
65
+ const templateId = rule.extra.templateId;
66
+ if (typeof templateId !== "string" || templateId.length === 0) return;
67
+ const template = artifacts.get(templateId);
68
+ if (!template) throw new Error(`rule template "${templateId}" not found`);
69
+ if (template.subtype !== "artifact-template" || template.extra.targetKind !== "rule") {
70
+ throw new Error(`artifact "${templateId}" is not a Rule artifact template`);
71
+ }
72
+ const required = Array.isArray(template.extra.completionRequired)
73
+ ? template.extra.completionRequired.filter((field): field is string => typeof field === "string")
74
+ : [];
75
+ for (const field of required) {
76
+ if (!isPresent(valueAtPath(rule, field))) {
77
+ throw new Error(`rule does not conform to template "${templateId}": missing completion-required field "${field}"`);
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * A Rule's condition+action+body is injected into every relevant turn for the rule's entire
84
+ * lifetime -- a permanent tax on every future turn's context budget, not a one-time cost.
85
+ * Rejects (rather than silently truncating or merely warning) once a rule is unambiguously
86
+ * bloated, since a silently-truncated rule would inject different text than what its author
87
+ * reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
88
+ * own comment in constants.ts for the research this threshold is grounded in.
89
+ */
90
+ export function ruleCombinedLength(condition: string | undefined, action: string | undefined, body: string | undefined): number {
91
+ return (condition ?? "").length + (action ?? "").length + (body ?? "").length;
92
+ }
93
+
94
+ /**
95
+ * Non-blocking counterpart to assertRuleTextWithinBounds's hard rejection: the same combined
96
+ * length, informational once it crosses the soft target, so a caller doesn't have to self-police
97
+ * with a manual character count before every rules.create/update. Returns undefined at or under
98
+ * the target -- the common case, not worth a field only ever seen as "undefined" on the wire.
99
+ */
100
+ export function ruleCombinedLengthWarning(combinedLength: number): string | undefined {
101
+ if (combinedLength <= RULE_TEXT_SOFT_TARGET_CHARACTERS) return undefined;
102
+ return (
103
+ `condition+action+body is ${combinedLength} characters, over the ${RULE_TEXT_SOFT_TARGET_CHARACTERS}-character soft target ` +
104
+ `(hard limit ${RULE_TEXT_HARD_LIMIT_CHARACTERS}) -- consider moving detail into a linked Doc.`
105
+ );
106
+ }
107
+
108
+ function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
109
+ const combined = ruleCombinedLength(condition, action, body);
110
+ if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
111
+ throw new Error(
112
+ `rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
113
+ "A Rule is injected into every relevant turn for its entire lifetime -- this is a permanent context-budget tax, not a one-time cost. " +
114
+ "Split it: keep a short Rule (the condition and the invariant itself), and move the full reasoning, examples, and research into a linked Doc.",
115
+ );
116
+ }
117
+ }
118
+
119
+ export function createRule(
120
+ artifacts: ArtifactStore,
121
+ scopes: ArtifactScopeStore,
122
+ input: CreateRuleInput,
123
+ context?: ArtifactEventContext,
124
+ ): Artifact {
125
+ assertRuleTextWithinBounds(input.condition, input.action, input.body);
126
+ const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
127
+ const rule = artifacts.create(
128
+ {
129
+ kind: "rule",
130
+ status: input.templateId === undefined ? "active" : "draft",
131
+ title: input.title,
132
+ body: input.body,
133
+ subtype: input.subtype,
134
+ labels: input.labels,
135
+ extra: {
136
+ ...(input.extra ?? {}),
137
+ ...(input.condition ? { condition: input.condition } : {}),
138
+ ...(input.action ? { action: input.action } : {}),
139
+ severity: input.severity ?? "info",
140
+ ...(input.templateId === undefined ? {} : { templateId: input.templateId }),
141
+ },
142
+ templateId: input.templateId,
143
+ },
144
+ context,
145
+ );
146
+ scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
147
+ return rule;
148
+ }
149
+
150
+ export function listRules(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
151
+ return listScoped(artifacts, scopes, "rule", filter);
152
+ }
153
+
154
+ export function assignRuleProject(
155
+ artifacts: ArtifactStore,
156
+ scopes: ArtifactScopeStore,
157
+ id: string,
158
+ projectRoot: string | undefined,
159
+ ): Artifact {
160
+ return assignArtifactProject(artifacts, scopes, id, "rule", projectRoot);
161
+ }
162
+
163
+ /**
164
+ * Global rules always apply; scoped workflow-run rules apply only while their run owns active
165
+ * focus. Both a workflow-definition target's own run scope ("skill-run", written by
166
+ * workflow-execution.ts's runWorkflowSteps for that target kind) and a Playbook's own run scope
167
+ * ("playbook-run", same call for a Playbook target) are recognized -- confirmed live that only
168
+ * "skill-run" was ever checked here, silently breaking Playbook-run-scoped rule injection since
169
+ * Playbook gained its own doc/rule structured steps.
170
+ */
171
+ export function listInjectableRules(artifacts: ArtifactStore, activeTaskId?: string): Artifact[] {
172
+ return artifacts.query({ kind: "rule", status: "active" }).filter((rule) => {
173
+ if (rule.subtype === "artifact-template") return false;
174
+ const scope = rule.extra.scope;
175
+ if (scope === undefined) return true;
176
+ if (typeof scope !== "object" || scope === null || Array.isArray(scope)) return false;
177
+ const value = scope as Record<string, unknown>;
178
+ if ((value.type !== "skill-run" && value.type !== "playbook-run") || !Array.isArray(value.taskIds)) return false;
179
+ return activeTaskId !== undefined && value.taskIds.some((id) => id === activeTaskId);
180
+ });
181
+ }
182
+
183
+ export function showRule(artifacts: ArtifactStore, id: string): Artifact {
184
+ requireKind(artifacts, id, "rule");
185
+ return artifacts.get(id, { tree: true })!;
186
+ }
187
+
188
+ export function previewRule(artifacts: ArtifactStore, id: string): string {
189
+ const rule = requireKind(artifacts, id, "rule");
190
+ const condition = typeof rule.extra.condition === "string" ? ` (when: ${rule.extra.condition})` : "";
191
+ const action = rule.body || (typeof rule.extra.action === "string" ? rule.extra.action : "");
192
+ return `• ${rule.title}${condition}\n ${action}`;
193
+ }
194
+
195
+ export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
196
+ const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
197
+ if (action === "enable" && (rule.status === "draft" || rule.status === "deprecated")) assertTemplateConformance(artifacts, rule);
198
+ return runTransition(artifacts, rule, "rule", action, RULE_TRANSITIONS, context);
199
+ }
200
+
201
+ export type UpdateRuleInput = UpdateContentInput;
202
+
203
+ /** A Rule's body update stays under the same combined condition+action+body ceiling as creation -- a permanent per-turn injection cost doesn't get looser just because it's an edit, not a create. */
204
+ export function updateRule(artifacts: ArtifactStore, id: string, input: UpdateRuleInput, context?: ArtifactEventContext): Artifact {
205
+ requireContentUpdateFields(input);
206
+ assertTitleBounds(input.title);
207
+ assertLabelsBounds(input.labels);
208
+ const rule = requireLocallyOwnedContent(requireKind(artifacts, id, "rule"));
209
+ if (input.body !== undefined) {
210
+ const condition = typeof rule.extra.condition === "string" ? rule.extra.condition : undefined;
211
+ const action = typeof rule.extra.action === "string" ? rule.extra.action : undefined;
212
+ assertRuleTextWithinBounds(condition, action, input.body);
213
+ }
214
+ const updated = artifacts.updateContent(id, input, context);
215
+ if (!updated) throw new Error(`rule "${id}" not found`);
216
+ return updated;
217
+ }
218
+
219
+ export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
220
+ requireLocallyOwnedContent(requireKind(artifacts, ruleId, "rule"));
221
+ requireKind(artifacts, taskId, "task");
222
+ artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
223
+ return showRule(artifacts, ruleId);
224
+ }
package/src/service.ts CHANGED
@@ -15,7 +15,6 @@ import { migrateDb, openDb, schemaVersion } from "./db.ts";
15
15
  import { Discussions } from "./discussion/discussion-service.ts";
16
16
  import type { TaskEventContext } from "./domain/task-event.ts";
17
17
  import type { TaskViewMode } from "./domain/task-scope.ts";
18
- import { listInjectableRules } from "./domain-services.ts";
19
18
  import { createPapyrusVehicleRegistry } from "./handlers/registry.ts";
20
19
  import { logEvent } from "./log/log.ts";
21
20
  import { Logs } from "./log/log-service.ts";
@@ -31,6 +30,7 @@ import { RULES_OPERATION_NAMES, rulesOperations } from "./modules/rules.ts";
31
30
  import { SESSION_IDENTITY_OPERATION_NAMES, sessionIdentityOperations } from "./modules/session-identity.ts";
32
31
  import { TASKS_OPERATION_NAMES, tasksOperations } from "./modules/tasks.ts";
33
32
  import { NOTE_SUBTYPE, Notes } from "./note/note-service.ts";
33
+ import { listInjectableRules } from "./rules/rules-service.ts";
34
34
  import { InvalidSessionSecretError, SessionIdentity } from "./session-identity/session-identity-service.ts";
35
35
  import type { GateRunner } from "./stores/gate-runner.ts";
36
36
  import { SQLiteDiscussionRoundStore } from "./stores/sqlite-discussion-round-store.ts";
@@ -39,6 +39,7 @@ import { SQLiteGraphProjectionStore } from "./stores/sqlite-graph-projection-sto
39
39
  import { SQLiteLogStore } from "./stores/sqlite-log-store.ts";
40
40
  import { SQLiteNoteEventStore } from "./stores/sqlite-note-event-store.ts";
41
41
  import { SQLiteSessionIdentityStore } from "./stores/sqlite-session-identity-store.ts";
42
+ import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-request-store.ts";
42
43
  import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
43
44
  import { SQLiteTaskFocusStore } from "./stores/sqlite-task-focus-store.ts";
44
45
  import { SQLiteTaskLeaseStore } from "./stores/sqlite-task-lease-store.ts";
@@ -352,6 +353,9 @@ function handlers(
352
353
  "tasks.plan": forwardToModule("tasks.plan"),
353
354
  "tasks.show": forwardToModule("tasks.show"),
354
355
  "tasks.history": forwardToModule("tasks.history"),
356
+ "tasks.projects": forwardToModule("tasks.projects"),
357
+ "tasks.resolve_project": forwardToModule("tasks.resolve_project"),
358
+ "tasks.register_project": forwardToModule("tasks.register_project"),
355
359
  "tasks.scope": forwardToModule("tasks.scope"),
356
360
  "tasks.set_scope": forwardToModule("tasks.set_scope"),
357
361
  "tasks.assign_project": forwardToModule("tasks.assign_project"),
@@ -449,7 +453,8 @@ export function createPapyrusService(path: string): PapyrusService {
449
453
  const events = new SQLiteTaskEventStore(db);
450
454
  const scopes = new SQLiteTaskScopeStore(db);
451
455
  const leases = new SQLiteTaskLeaseStore(db);
452
- const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases);
456
+ const createRequests = new SQLiteTaskCreateRequestStore(db);
457
+ const tasks = new Tasks(artifacts, gates, focus, events, scopes, leases, createRequests);
453
458
  const noteEvents = new SQLiteNoteEventStore(db);
454
459
  const notes = new Notes(artifacts, noteEvents);
455
460
  const projections = new SQLiteGraphProjectionStore(db);
@@ -0,0 +1,47 @@
1
+ import type { Db } from "../db.ts";
2
+ import type { TaskCreateRequestRecord, TaskCreateRequestStore } from "./task-create-request-store.ts";
3
+
4
+ export class SQLiteTaskCreateRequestStore implements TaskCreateRequestStore {
5
+ constructor(private readonly db: Db) {}
6
+
7
+ get(scope: string, key: string, now: string): TaskCreateRequestRecord | undefined {
8
+ const row = this.db
9
+ .prepare(`
10
+ SELECT request_scope, idempotency_key, request_hash, response_json, created_at, expires_at
11
+ FROM task_create_requests
12
+ WHERE request_scope = ? AND idempotency_key = ? AND expires_at > ?
13
+ `)
14
+ .get(scope, key, now) as {
15
+ request_scope: string;
16
+ idempotency_key: string;
17
+ request_hash: string;
18
+ response_json: string;
19
+ created_at: string;
20
+ expires_at: string;
21
+ } | null;
22
+ return row
23
+ ? {
24
+ scope: row.request_scope,
25
+ key: row.idempotency_key,
26
+ requestHash: row.request_hash,
27
+ responseJson: row.response_json,
28
+ createdAt: row.created_at,
29
+ expiresAt: row.expires_at,
30
+ }
31
+ : undefined;
32
+ }
33
+
34
+ put(record: TaskCreateRequestRecord): void {
35
+ this.db
36
+ .prepare(`
37
+ INSERT INTO task_create_requests
38
+ (request_scope, idempotency_key, request_hash, response_json, created_at, expires_at)
39
+ VALUES (?, ?, ?, ?, ?, ?)
40
+ `)
41
+ .run(record.scope, record.key, record.requestHash, record.responseJson, record.createdAt, record.expiresAt);
42
+ }
43
+
44
+ prune(now: string): number {
45
+ return this.db.prepare("DELETE FROM task_create_requests WHERE expires_at <= ?").run(now).changes;
46
+ }
47
+ }
@@ -1,22 +1,68 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
1
4
  import type { Db } from "../db.ts";
2
5
  import { inTransaction } from "../db.ts";
3
- import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
6
+ import type {
7
+ RegisterTaskProjectInput,
8
+ TaskProject,
9
+ TaskProjectScope,
10
+ TaskScopeSource,
11
+ TaskViewMode,
12
+ TaskViewPreference,
13
+ } from "../domain/task-scope.ts";
4
14
  import type { TaskScopeStore } from "./task-scope-store.ts";
5
15
 
16
+ interface ProjectRow {
17
+ id: string;
18
+ name: string;
19
+ aliases_json: string;
20
+ project_root: string;
21
+ created_at: string;
22
+ updated_at: string;
23
+ }
24
+
25
+ function projectFromRow(row: ProjectRow): TaskProject {
26
+ return {
27
+ id: row.id,
28
+ name: row.name,
29
+ aliases: JSON.parse(row.aliases_json) as string[],
30
+ projectRoot: row.project_root,
31
+ createdAt: row.created_at,
32
+ updatedAt: row.updated_at,
33
+ };
34
+ }
35
+
36
+ function uniqueAliases(values: readonly string[], name: string): string[] {
37
+ const seen = new Set([name.trim().toLowerCase()]);
38
+ const aliases = values.flatMap((value) => {
39
+ const trimmed = value.trim();
40
+ const key = trimmed.toLowerCase();
41
+ if (!trimmed || seen.has(key)) return [];
42
+ seen.add(key);
43
+ return [trimmed];
44
+ });
45
+ if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
46
+ throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
47
+ }
48
+ return aliases;
49
+ }
50
+
6
51
  export class SQLiteTaskScopeStore implements TaskScopeStore {
7
52
  constructor(private readonly db: Db) {}
8
53
 
9
54
  assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
10
55
  inTransaction(this.db, () => {
56
+ if (projectRoot !== undefined) this.registerProject({ projectRoot });
11
57
  this.db
12
58
  .prepare(`
13
- INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
14
- VALUES (?, ?, ?, ?)
15
- ON CONFLICT(task_id) DO UPDATE SET
16
- project_root = excluded.project_root,
17
- source = excluded.source,
18
- assigned_at = excluded.assigned_at
19
- `)
59
+ INSERT INTO task_scopes (task_id, project_root, source, assigned_at)
60
+ VALUES (?, ?, ?, ?)
61
+ ON CONFLICT(task_id) DO UPDATE SET
62
+ project_root = excluded.project_root,
63
+ source = excluded.source,
64
+ assigned_at = excluded.assigned_at
65
+ `)
20
66
  .run(taskId, projectRoot ?? null, source, new Date().toISOString());
21
67
  });
22
68
  return { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
@@ -56,15 +102,108 @@ export class SQLiteTaskScopeStore implements TaskScopeStore {
56
102
  inTransaction(this.db, () => {
57
103
  this.db
58
104
  .prepare(`
59
- INSERT INTO task_views (project_root, mode, root_task_id, updated_at)
60
- VALUES (?, ?, ?, ?)
61
- ON CONFLICT(project_root) DO UPDATE SET
62
- mode = excluded.mode,
63
- root_task_id = excluded.root_task_id,
64
- updated_at = excluded.updated_at
65
- `)
105
+ INSERT INTO task_views (project_root, mode, root_task_id, updated_at)
106
+ VALUES (?, ?, ?, ?)
107
+ ON CONFLICT(project_root) DO UPDATE SET
108
+ mode = excluded.mode,
109
+ root_task_id = excluded.root_task_id,
110
+ updated_at = excluded.updated_at
111
+ `)
66
112
  .run(projectRoot, mode, rootTaskId ?? null, new Date().toISOString());
67
113
  });
68
114
  return { projectRoot, mode, ...(rootTaskId === undefined ? {} : { rootTaskId }) };
69
115
  }
116
+
117
+ projects(query: string | undefined, limit: number): TaskProject[] {
118
+ const needle = query?.trim().toLowerCase();
119
+ if (!needle) {
120
+ return (
121
+ this.db
122
+ .prepare(
123
+ "SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects ORDER BY name, project_root LIMIT ?",
124
+ )
125
+ .all(limit) as ProjectRow[]
126
+ ).map(projectFromRow);
127
+ }
128
+ return (
129
+ this.db
130
+ .prepare(`
131
+ SELECT id, name, aliases_json, project_root, created_at, updated_at
132
+ FROM task_projects
133
+ WHERE instr(lower(name), ?) > 0
134
+ OR instr(lower(project_root), ?) > 0
135
+ OR EXISTS (
136
+ SELECT 1 FROM json_each(task_projects.aliases_json)
137
+ WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0
138
+ )
139
+ ORDER BY name, project_root
140
+ LIMIT ?
141
+ `)
142
+ .all(needle, needle, needle, limit) as ProjectRow[]
143
+ ).map(projectFromRow);
144
+ }
145
+
146
+ matchingProjects(reference: string): TaskProject[] {
147
+ const needle = reference.trim().toLowerCase();
148
+ return (
149
+ this.db
150
+ .prepare(`
151
+ SELECT id, name, aliases_json, project_root, created_at, updated_at
152
+ FROM task_projects
153
+ WHERE lower(id) = ? OR lower(name) = ? OR lower(project_root) = ?
154
+ OR EXISTS (
155
+ SELECT 1 FROM json_each(task_projects.aliases_json)
156
+ WHERE lower(CAST(json_each.value AS TEXT)) = ?
157
+ )
158
+ ORDER BY name, project_root
159
+ LIMIT 11
160
+ `)
161
+ .all(needle, needle, needle, needle) as ProjectRow[]
162
+ ).map(projectFromRow);
163
+ }
164
+
165
+ registerProject(input: RegisterTaskProjectInput): TaskProject {
166
+ return inTransaction(this.db, () => {
167
+ const row = (
168
+ input.existingId
169
+ ? this.db
170
+ .prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE id = ?")
171
+ .get(input.existingId)
172
+ : this.db
173
+ .prepare("SELECT id, name, aliases_json, project_root, created_at, updated_at FROM task_projects WHERE project_root = ?")
174
+ .get(input.projectRoot)
175
+ ) as ProjectRow | null;
176
+ const existing = row ? projectFromRow(row) : undefined;
177
+ const now = new Date().toISOString();
178
+ const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
179
+ const aliases = uniqueAliases(
180
+ [...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
181
+ name,
182
+ );
183
+ const project: TaskProject = {
184
+ id: existing?.id ?? randomUUID(),
185
+ name,
186
+ aliases,
187
+ projectRoot: input.projectRoot,
188
+ createdAt: existing?.createdAt ?? now,
189
+ updatedAt: now,
190
+ };
191
+ if (existing && existing.projectRoot !== project.projectRoot) {
192
+ this.db.prepare("UPDATE task_scopes SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
193
+ this.db.prepare("UPDATE task_views SET project_root = ? WHERE project_root = ?").run(project.projectRoot, existing.projectRoot);
194
+ }
195
+ this.db
196
+ .prepare(`
197
+ INSERT INTO task_projects (id, name, aliases_json, project_root, created_at, updated_at)
198
+ VALUES (?, ?, ?, ?, ?, ?)
199
+ ON CONFLICT(id) DO UPDATE SET
200
+ name = excluded.name,
201
+ aliases_json = excluded.aliases_json,
202
+ project_root = excluded.project_root,
203
+ updated_at = excluded.updated_at
204
+ `)
205
+ .run(project.id, project.name, JSON.stringify(project.aliases), project.projectRoot, project.createdAt, project.updatedAt);
206
+ return project;
207
+ });
208
+ }
70
209
  }
@@ -0,0 +1,45 @@
1
+ export interface TaskCreateRequestRecord {
2
+ scope: string;
3
+ key: string;
4
+ requestHash: string;
5
+ responseJson: string;
6
+ createdAt: string;
7
+ expiresAt: string;
8
+ }
9
+
10
+ export interface TaskCreateRequestStore {
11
+ get(scope: string, key: string, now: string): TaskCreateRequestRecord | undefined;
12
+ put(record: TaskCreateRequestRecord): void;
13
+ prune(now: string): number;
14
+ }
15
+
16
+ export class TaskCreateIdempotencyConflictError extends Error {}
17
+
18
+ export class InMemoryTaskCreateRequestStore implements TaskCreateRequestStore {
19
+ private readonly records = new Map<string, TaskCreateRequestRecord>();
20
+
21
+ private recordKey(scope: string, key: string): string {
22
+ return `${scope}\u0000${key}`;
23
+ }
24
+
25
+ get(scope: string, key: string, now: string): TaskCreateRequestRecord | undefined {
26
+ const record = this.records.get(this.recordKey(scope, key));
27
+ if (!record || record.expiresAt <= now) return undefined;
28
+ return record;
29
+ }
30
+
31
+ put(record: TaskCreateRequestRecord): void {
32
+ this.records.set(this.recordKey(record.scope, record.key), record);
33
+ }
34
+
35
+ prune(now: string): number {
36
+ let removed = 0;
37
+ for (const [key, record] of this.records) {
38
+ if (record.expiresAt <= now) {
39
+ this.records.delete(key);
40
+ removed += 1;
41
+ }
42
+ }
43
+ return removed;
44
+ }
45
+ }
@@ -1,4 +1,14 @@
1
- import type { TaskProjectScope, TaskScopeSource, TaskViewMode, TaskViewPreference } from "../domain/task-scope.ts";
1
+ import { randomUUID } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { TASK_PROJECT_ALIAS_MAX_COUNT } from "../constants.ts";
4
+ import type {
5
+ RegisterTaskProjectInput,
6
+ TaskProject,
7
+ TaskProjectScope,
8
+ TaskScopeSource,
9
+ TaskViewMode,
10
+ TaskViewPreference,
11
+ } from "../domain/task-scope.ts";
2
12
 
3
13
  export interface TaskScopeStore {
4
14
  assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope;
@@ -6,15 +16,37 @@ export interface TaskScopeStore {
6
16
  taskIds(projectRoot: string | undefined, limit: number): string[];
7
17
  view(projectRoot: string): TaskViewPreference;
8
18
  setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewPreference;
19
+ projects(query: string | undefined, limit: number): TaskProject[];
20
+ matchingProjects(reference: string): TaskProject[];
21
+ registerProject(input: RegisterTaskProjectInput): TaskProject;
22
+ }
23
+
24
+ function uniqueAliases(values: readonly string[], name: string): string[] {
25
+ const seen = new Set([name.trim().toLowerCase()]);
26
+ const aliases = values.flatMap((value) => {
27
+ const trimmed = value.trim();
28
+ const key = trimmed.toLowerCase();
29
+ if (!trimmed || seen.has(key)) return [];
30
+ seen.add(key);
31
+ return [trimmed];
32
+ });
33
+ if (aliases.length > TASK_PROJECT_ALIAS_MAX_COUNT) {
34
+ throw new Error(`project aliases cannot exceed ${TASK_PROJECT_ALIAS_MAX_COUNT} entries`);
35
+ }
36
+ return aliases;
9
37
  }
10
38
 
11
39
  export class InMemoryTaskScopeStore implements TaskScopeStore {
12
40
  private readonly scopes = new Map<string, TaskProjectScope>();
13
41
  private readonly views = new Map<string, TaskViewPreference>();
42
+ private readonly projectRows = new Map<string, TaskProject>();
14
43
 
15
44
  assign(taskId: string, projectRoot: string | undefined, source: TaskScopeSource): TaskProjectScope {
16
45
  const scope = { taskId, ...(projectRoot === undefined ? {} : { projectRoot }), source };
17
46
  this.scopes.set(taskId, scope);
47
+ if (projectRoot !== undefined && ![...this.projectRows.values()].some((project) => project.projectRoot === projectRoot)) {
48
+ this.registerProject({ projectRoot });
49
+ }
18
50
  return scope;
19
51
  }
20
52
 
@@ -39,4 +71,62 @@ export class InMemoryTaskScopeStore implements TaskScopeStore {
39
71
  this.views.set(projectRoot, view);
40
72
  return view;
41
73
  }
74
+
75
+ projects(query: string | undefined, limit: number): TaskProject[] {
76
+ const needle = query?.trim().toLowerCase();
77
+ return [...this.projectRows.values()]
78
+ .filter(
79
+ (project) =>
80
+ !needle ||
81
+ project.name.toLowerCase().includes(needle) ||
82
+ project.projectRoot.toLowerCase().includes(needle) ||
83
+ project.aliases.some((alias) => alias.toLowerCase().includes(needle)),
84
+ )
85
+ .sort((left, right) => left.name.localeCompare(right.name) || left.projectRoot.localeCompare(right.projectRoot))
86
+ .slice(0, limit);
87
+ }
88
+
89
+ matchingProjects(reference: string): TaskProject[] {
90
+ const needle = reference.trim().toLowerCase();
91
+ return [...this.projectRows.values()]
92
+ .filter(
93
+ (project) =>
94
+ project.id.toLowerCase() === needle ||
95
+ project.name.toLowerCase() === needle ||
96
+ project.projectRoot.toLowerCase() === needle ||
97
+ project.aliases.some((alias) => alias.toLowerCase() === needle),
98
+ )
99
+ .slice(0, 11);
100
+ }
101
+
102
+ registerProject(input: RegisterTaskProjectInput): TaskProject {
103
+ const now = new Date().toISOString();
104
+ const byRoot = [...this.projectRows.values()].find((project) => project.projectRoot === input.projectRoot);
105
+ const existing = input.existingId ? this.projectRows.get(input.existingId) : byRoot;
106
+ const name = input.name?.trim() || existing?.name || basename(input.projectRoot) || input.projectRoot;
107
+ const aliases = uniqueAliases(
108
+ [...(existing?.aliases ?? []), ...(existing && existing.name !== name ? [existing.name] : []), ...(input.aliases ?? [])],
109
+ name,
110
+ );
111
+ const project: TaskProject = {
112
+ id: existing?.id ?? randomUUID(),
113
+ name,
114
+ aliases,
115
+ projectRoot: input.projectRoot,
116
+ createdAt: existing?.createdAt ?? now,
117
+ updatedAt: now,
118
+ };
119
+ if (existing && existing.projectRoot !== project.projectRoot) {
120
+ for (const [taskId, scope] of this.scopes) {
121
+ if (scope.projectRoot === existing.projectRoot) this.scopes.set(taskId, { ...scope, projectRoot: project.projectRoot });
122
+ }
123
+ const view = this.views.get(existing.projectRoot);
124
+ if (view) {
125
+ this.views.delete(existing.projectRoot);
126
+ this.views.set(project.projectRoot, { ...view, projectRoot: project.projectRoot });
127
+ }
128
+ }
129
+ this.projectRows.set(project.id, project);
130
+ return project;
131
+ }
42
132
  }