@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.
- package/README.md +10 -0
- package/package.json +1 -1
- package/src/authority-registry.ts +2 -1
- package/src/cli/task-command.ts +89 -4
- package/src/constants.ts +6 -1
- package/src/db.ts +68 -1
- package/src/docs/docs-service.ts +192 -0
- package/src/domain/task-lease.ts +6 -0
- package/src/domain/task-scope.ts +16 -0
- package/src/domain-service-shared.ts +162 -0
- package/src/handlers/docs.ts +1 -1
- package/src/handlers/playbooks.ts +3 -1
- package/src/handlers/rules.ts +5 -3
- package/src/handlers/shared.ts +16 -1
- package/src/handlers/tasks.ts +135 -16
- package/src/index.ts +1 -1
- package/src/modules/docs.ts +2 -2
- package/src/modules/notes.ts +2 -2
- package/src/modules/playbooks.ts +6 -4
- package/src/modules/rules.ts +4 -2
- package/src/modules/tasks.ts +25 -2
- package/src/playbook/playbook-definition.ts +1 -1
- package/src/{domain-services.ts → playbook/playbook-service.ts} +53 -453
- package/src/rules/rules-service.ts +224 -0
- package/src/service.ts +7 -2
- package/src/stores/sqlite-task-create-request-store.ts +47 -0
- package/src/stores/sqlite-task-scope-store.ts +154 -15
- package/src/stores/task-create-request-store.ts +45 -0
- package/src/stores/task-scope-store.ts +91 -1
- package/src/task/task-service.ts +131 -13
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
}
|