@danypops/papyrus 0.11.3 → 0.12.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 +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +90 -37
- package/extension/src/notes.ts +14 -1
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +38 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +336 -8
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +133 -38
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/task-service.ts +70 -38
package/src/ops.ts
CHANGED
|
@@ -9,6 +9,16 @@ import { inTransaction } from "./db.ts";
|
|
|
9
9
|
import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
|
|
10
10
|
import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
|
|
11
11
|
import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
|
|
12
|
+
import {
|
|
13
|
+
normalizeArtifactEventQuery,
|
|
14
|
+
resolveArtifactEvent,
|
|
15
|
+
type AppendArtifactEvent,
|
|
16
|
+
type ArtifactEvent,
|
|
17
|
+
type ArtifactEventContext,
|
|
18
|
+
type ArtifactEventPage,
|
|
19
|
+
type ArtifactEventQuery,
|
|
20
|
+
type ArtifactEventType,
|
|
21
|
+
} from "./domain/artifact-event.ts";
|
|
12
22
|
export type { Artifact } from "./domain/artifact.ts";
|
|
13
23
|
export type { Gate, GateResult } from "./domain/gate.ts";
|
|
14
24
|
export type CreateInput = CreateArtifactInput;
|
|
@@ -93,15 +103,6 @@ function resolveCreateInput(db: Db, input: CreateInput): ResolvedCreateInput {
|
|
|
93
103
|
return merged as ResolvedCreateInput;
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
function slugify(s: string): string {
|
|
97
|
-
return s
|
|
98
|
-
.toLowerCase()
|
|
99
|
-
.replace(/[^a-z0-9\s-]/g, "")
|
|
100
|
-
.trim()
|
|
101
|
-
.replace(/\s+/g, "-")
|
|
102
|
-
.slice(0, 60) + "-" + Math.random().toString(36).slice(2, 6);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
106
|
function defaultStatusFor(db: Db, kind: string): string {
|
|
106
107
|
// Explicit per-kind mapping, never row order -- see DEFAULT_STATUS_BY_KIND's doc comment
|
|
107
108
|
// for the production defect this replaced (row order is not a semantic guarantee).
|
|
@@ -127,9 +128,114 @@ function rowToArtifact(row: Record<string, unknown>): Artifact {
|
|
|
127
128
|
};
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Appends one immutable row to the generic, kind-agnostic mutation event log.
|
|
133
|
+
* This is the one choke point every ArtifactStore mutation funnels through, so every
|
|
134
|
+
* kind (doc, task, rule, skill) gets an audit trail for free — no domain call site
|
|
135
|
+
* can skip it. See src/domain/artifact-event.ts for why actor/source always default
|
|
136
|
+
* to explicit sentinels rather than a silently blank column.
|
|
137
|
+
*/
|
|
138
|
+
export function appendArtifactEvent(db: Db, input: AppendArtifactEvent): ArtifactEvent {
|
|
139
|
+
const event = resolveArtifactEvent(input);
|
|
140
|
+
const now = new Date().toISOString();
|
|
141
|
+
let id: number | bigint = 0;
|
|
142
|
+
inTransaction(db, () => {
|
|
143
|
+
const result = db.prepare(`
|
|
144
|
+
INSERT INTO artifact_events (
|
|
145
|
+
artifact_id, occurred_at, event_type, actor, source, session_id,
|
|
146
|
+
from_status, to_status, relation, related_id, event_schema_version
|
|
147
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
|
148
|
+
`).run(
|
|
149
|
+
event.artifactId,
|
|
150
|
+
now,
|
|
151
|
+
event.type,
|
|
152
|
+
event.actor,
|
|
153
|
+
event.source,
|
|
154
|
+
event.sessionId ?? null,
|
|
155
|
+
event.fromStatus ?? null,
|
|
156
|
+
event.toStatus ?? null,
|
|
157
|
+
event.relation ?? null,
|
|
158
|
+
event.relatedId ?? null,
|
|
159
|
+
);
|
|
160
|
+
id = result.lastInsertRowid;
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
id: Number(id),
|
|
164
|
+
artifactId: event.artifactId,
|
|
165
|
+
occurredAt: now,
|
|
166
|
+
type: event.type,
|
|
167
|
+
actor: event.actor,
|
|
168
|
+
source: event.source,
|
|
169
|
+
...(event.sessionId === undefined ? {} : { sessionId: event.sessionId }),
|
|
170
|
+
...(event.fromStatus === undefined ? {} : { fromStatus: event.fromStatus }),
|
|
171
|
+
...(event.toStatus === undefined ? {} : { toStatus: event.toStatus }),
|
|
172
|
+
...(event.relation === undefined ? {} : { relation: event.relation }),
|
|
173
|
+
...(event.relatedId === undefined ? {} : { relatedId: event.relatedId }),
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface ArtifactEventRow {
|
|
179
|
+
id: number;
|
|
180
|
+
artifact_id: string;
|
|
181
|
+
occurred_at: string;
|
|
182
|
+
event_type: ArtifactEventType;
|
|
183
|
+
actor: string;
|
|
184
|
+
source: string;
|
|
185
|
+
session_id: string | null;
|
|
186
|
+
from_status: string | null;
|
|
187
|
+
to_status: string | null;
|
|
188
|
+
relation: string | null;
|
|
189
|
+
related_id: string | null;
|
|
190
|
+
event_schema_version: 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function mapArtifactEventRow(row: ArtifactEventRow): ArtifactEvent {
|
|
194
|
+
return {
|
|
195
|
+
id: row.id,
|
|
196
|
+
artifactId: row.artifact_id,
|
|
197
|
+
occurredAt: row.occurred_at,
|
|
198
|
+
type: row.event_type,
|
|
199
|
+
actor: row.actor,
|
|
200
|
+
source: row.source,
|
|
201
|
+
...(row.session_id === null ? {} : { sessionId: row.session_id }),
|
|
202
|
+
...(row.from_status === null ? {} : { fromStatus: row.from_status }),
|
|
203
|
+
...(row.to_status === null ? {} : { toStatus: row.to_status }),
|
|
204
|
+
...(row.relation === null ? {} : { relation: row.relation }),
|
|
205
|
+
...(row.related_id === null ? {} : { relatedId: row.related_id }),
|
|
206
|
+
schemaVersion: row.event_schema_version,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Bounded query over the generic mutation event log — requires artifactId, actor, or sessionId to stay indexed. */
|
|
211
|
+
export function queryArtifactEvents(db: Db, query: ArtifactEventQuery): ArtifactEventPage {
|
|
212
|
+
const { artifactId, actor, sessionId, since, limit, direction, cursor } = normalizeArtifactEventQuery(query);
|
|
213
|
+
const conditions: string[] = [];
|
|
214
|
+
const params: unknown[] = [];
|
|
215
|
+
if (artifactId) { conditions.push("(artifact_id = ? OR related_id = ?)"); params.push(artifactId, artifactId); }
|
|
216
|
+
if (actor) { conditions.push("actor = ?"); params.push(actor); }
|
|
217
|
+
if (sessionId) { conditions.push("session_id = ?"); params.push(sessionId); }
|
|
218
|
+
if (since) { conditions.push("occurred_at >= ?"); params.push(since); }
|
|
219
|
+
const comparator = direction === "desc" ? "<" : ">";
|
|
220
|
+
if (cursor !== undefined) { conditions.push(`id ${comparator} ?`); params.push(cursor); }
|
|
221
|
+
const order = direction === "desc" ? "DESC" : "ASC";
|
|
222
|
+
const rows = db.prepare(`
|
|
223
|
+
SELECT * FROM artifact_events
|
|
224
|
+
WHERE ${conditions.join(" AND ")}
|
|
225
|
+
ORDER BY occurred_at ${order}, id ${order}
|
|
226
|
+
LIMIT ?
|
|
227
|
+
`).all(...params, limit + 1) as ArtifactEventRow[];
|
|
228
|
+
const hasMore = rows.length > limit;
|
|
229
|
+
const events = rows.slice(0, limit).map(mapArtifactEventRow);
|
|
230
|
+
return { events, ...(hasMore ? { nextCursor: events.at(-1)!.id } : {}) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createArtifact(db: Db, input: CreateInput, context?: ArtifactEventContext): Artifact {
|
|
131
234
|
const resolved = resolveCreateInput(db, input);
|
|
132
|
-
|
|
235
|
+
// id is an opaque backend identity, never derived from title -- a title-derived slug
|
|
236
|
+
// conflated "identity" with "human-readable label" and leaked a bit of randomness into
|
|
237
|
+
// both. crypto.randomUUID() is native to Bun/Node; no dependency needed for this.
|
|
238
|
+
const id = resolved.id ?? crypto.randomUUID();
|
|
133
239
|
const status = resolved.status ?? defaultStatusFor(db, resolved.kind);
|
|
134
240
|
const now = new Date().toISOString();
|
|
135
241
|
const labels = JSON.stringify(resolved.labels ?? []);
|
|
@@ -140,6 +246,7 @@ export function createArtifact(db: Db, input: CreateInput): Artifact {
|
|
|
140
246
|
"INSERT INTO artifacts (id, kind, title, status, subtype, body, labels, extra, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
141
247
|
);
|
|
142
248
|
stmt.run(id, resolved.kind, resolved.title, status, subtype, resolved.body ?? "", labels, extra, now, now);
|
|
249
|
+
appendArtifactEvent(db, { artifactId: id, type: "created", toStatus: status, ...context });
|
|
143
250
|
});
|
|
144
251
|
return getArtifact(db, id)!;
|
|
145
252
|
}
|
|
@@ -211,7 +318,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
|
|
|
211
318
|
return rows.map(rowToArtifact);
|
|
212
319
|
}
|
|
213
320
|
|
|
214
|
-
export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string): void {
|
|
321
|
+
export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): void {
|
|
215
322
|
const fromArt = getArtifact(db, fromId);
|
|
216
323
|
const toArt = getArtifact(db, toId);
|
|
217
324
|
if (!fromArt || !toArt) throw new Error("artifact not found");
|
|
@@ -219,11 +326,28 @@ export function linkArtifacts(db: Db, fromId: string, relation: string, toId: st
|
|
|
219
326
|
const allowed = db.prepare("SELECT 1 FROM relation_names WHERE name = ?").get(relation);
|
|
220
327
|
if (!allowed) throw new Error(`unknown relation "${relation}" — register it first`);
|
|
221
328
|
inTransaction(db, () => {
|
|
329
|
+
const existed = db.prepare("SELECT 1 FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").get(fromId, relation, toId);
|
|
222
330
|
db.prepare("INSERT OR IGNORE INTO edges (from_id, relation, to_id) VALUES (?, ?, ?)").run(fromId, relation, toId);
|
|
331
|
+
if (!existed) {
|
|
332
|
+
appendArtifactEvent(db, { artifactId: fromId, type: "linked", relation, relatedId: toId, ...context });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Idempotent: removing an already-absent relationship is a no-op that returns false, not an error. */
|
|
338
|
+
export function unlinkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): boolean {
|
|
339
|
+
let removed = false;
|
|
340
|
+
inTransaction(db, () => {
|
|
341
|
+
const existed = db.prepare("SELECT 1 FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").get(fromId, relation, toId);
|
|
342
|
+
if (!existed) return;
|
|
343
|
+
db.prepare("DELETE FROM edges WHERE from_id = ? AND relation = ? AND to_id = ?").run(fromId, relation, toId);
|
|
344
|
+
appendArtifactEvent(db, { artifactId: fromId, type: "unlinked", relation, relatedId: toId, ...context });
|
|
345
|
+
removed = true;
|
|
223
346
|
});
|
|
347
|
+
return removed;
|
|
224
348
|
}
|
|
225
349
|
|
|
226
|
-
export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput): Artifact | null {
|
|
350
|
+
export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactInput, context?: ArtifactEventContext): Artifact | null {
|
|
227
351
|
const artifact = getArtifact(db, id);
|
|
228
352
|
if (!artifact) return null;
|
|
229
353
|
const now = new Date().toISOString();
|
|
@@ -235,11 +359,12 @@ export function updateArtifactContent(db: Db, id: string, input: UpdateArtifactI
|
|
|
235
359
|
now,
|
|
236
360
|
id,
|
|
237
361
|
);
|
|
362
|
+
appendArtifactEvent(db, { artifactId: id, type: "updated", ...context });
|
|
238
363
|
});
|
|
239
364
|
return getArtifact(db, id);
|
|
240
365
|
}
|
|
241
366
|
|
|
242
|
-
export function updateStatus(db: Db, id: string, status: string): Artifact | null {
|
|
367
|
+
export function updateStatus(db: Db, id: string, status: string, context?: ArtifactEventContext): Artifact | null {
|
|
243
368
|
const art = getArtifact(db, id);
|
|
244
369
|
if (!art) return null;
|
|
245
370
|
// Validate status is registered for this kind
|
|
@@ -248,15 +373,17 @@ export function updateStatus(db: Db, id: string, status: string): Artifact | nul
|
|
|
248
373
|
const now = new Date().toISOString();
|
|
249
374
|
inTransaction(db, () => {
|
|
250
375
|
db.prepare("UPDATE artifacts SET status = ?, updated_at = ? WHERE id = ?").run(status, now, id);
|
|
376
|
+
appendArtifactEvent(db, { artifactId: id, type: "status_changed", fromStatus: art.status, toStatus: status, ...context });
|
|
251
377
|
});
|
|
252
378
|
return getArtifact(db, id);
|
|
253
379
|
}
|
|
254
380
|
|
|
255
|
-
export function updateExtra(db: Db, id: string, extra: Record<string, unknown
|
|
381
|
+
export function updateExtra(db: Db, id: string, extra: Record<string, unknown>, context?: ArtifactEventContext): Artifact | null {
|
|
256
382
|
if (!getArtifact(db, id)) return null;
|
|
257
383
|
const now = new Date().toISOString();
|
|
258
384
|
inTransaction(db, () => {
|
|
259
385
|
db.prepare("UPDATE artifacts SET extra = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(extra), now, id);
|
|
386
|
+
appendArtifactEvent(db, { artifactId: id, type: "extra_set", ...context });
|
|
260
387
|
});
|
|
261
388
|
return getArtifact(db, id);
|
|
262
389
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TaskScopeSource } from "../domain/task-scope.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project scoping for Docs/Rules/Skills, mirroring TaskScopeStore's shape (task_scopes) but
|
|
5
|
+
* kept as its own table/port rather than folding non-Task kinds into Task-named
|
|
6
|
+
* infrastructure. TaskScopeSource ("cwd" | "explicit" | "unscoped") is already kind-agnostic
|
|
7
|
+
* and reused as-is -- no reason to redefine the same three values under a new name.
|
|
8
|
+
*/
|
|
9
|
+
export interface ArtifactScope {
|
|
10
|
+
artifactId: string;
|
|
11
|
+
projectRoot?: string;
|
|
12
|
+
source: TaskScopeSource;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ArtifactScopeStore {
|
|
16
|
+
assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): ArtifactScope;
|
|
17
|
+
get(artifactId: string): ArtifactScope | undefined;
|
|
18
|
+
/** Bounded id listing for one project (or the unscoped bucket when projectRoot is undefined). */
|
|
19
|
+
ids(projectRoot: string | undefined, limit: number): string[];
|
|
20
|
+
}
|
|
@@ -8,14 +8,19 @@ import type {
|
|
|
8
8
|
RelationshipQuery,
|
|
9
9
|
UpdateArtifactInput,
|
|
10
10
|
} from "../domain/artifact.ts";
|
|
11
|
+
import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
|
|
11
12
|
|
|
12
13
|
export interface ArtifactStore {
|
|
13
|
-
create(input: CreateArtifactInput): Artifact;
|
|
14
|
+
create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
|
|
14
15
|
get(id: string, options?: ArtifactGraphOptions): Artifact | null;
|
|
15
16
|
query(filter: ArtifactQuery): Artifact[];
|
|
16
|
-
link(link: ArtifactLink): void;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
link(link: ArtifactLink, context?: ArtifactEventContext): void;
|
|
18
|
+
/** Idempotent: removing an already-absent relationship is a no-op that returns false, not an error. */
|
|
19
|
+
unlink(link: ArtifactLink, context?: ArtifactEventContext): boolean;
|
|
20
|
+
setStatus(id: string, status: string, context?: ArtifactEventContext): Artifact | null;
|
|
21
|
+
setExtra(id: string, extra: Record<string, unknown>, context?: ArtifactEventContext): Artifact | null;
|
|
22
|
+
updateContent(id: string, input: UpdateArtifactInput, context?: ArtifactEventContext): Artifact | null;
|
|
20
23
|
relationships(filter?: RelationshipQuery): ArtifactEdge[];
|
|
24
|
+
/** Bounded query over the generic mutation event log shared by every kind. */
|
|
25
|
+
events(query: ArtifactEventQuery): ArtifactEventPage;
|
|
21
26
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistence port for ConversationJournal. Deliberately minimal and host-neutral: no
|
|
5
|
+
* mention of any host runtime, no query beyond what a bounded thread read needs. Idempotency
|
|
6
|
+
* (checking operationId before insert) is the service's job, not the store's -- this port is
|
|
7
|
+
* dumb storage, matching Discourse's own store/service split (see the layering decision doc).
|
|
8
|
+
*/
|
|
9
|
+
export interface ConversationJournalStore {
|
|
10
|
+
ensureThread(threadId: string): JournalThread;
|
|
11
|
+
getThread(threadId: string): JournalThread | undefined;
|
|
12
|
+
findPostByOperationId(operationId: string): JournalPost | undefined;
|
|
13
|
+
insertPost(post: JournalPost): void;
|
|
14
|
+
getPost(id: string): JournalPost | undefined;
|
|
15
|
+
/** All posts for one thread, unbounded at the store layer -- the service applies the read bound. */
|
|
16
|
+
postsForThread(threadId: string): readonly JournalPost[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProjectionCheckpoint } from "../domain/graph-projection.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Producer-scoped state a graph projection consumer needs beyond the generic ArtifactStore:
|
|
5
|
+
* the (producerId, externalId) -> Papyrus artifact id identity map, and the per-producer
|
|
6
|
+
* checkpoint. This is projection-specific bookkeeping, not Context Mesh content itself, so
|
|
7
|
+
* it is its own small port rather than bloating ArtifactStore.
|
|
8
|
+
*/
|
|
9
|
+
export interface GraphProjectionStore {
|
|
10
|
+
getCheckpoint(producerId: string): ProjectionCheckpoint | null;
|
|
11
|
+
resolveIdentity(producerId: string, externalId: string): string | undefined;
|
|
12
|
+
/** Idempotent: recording the same (producerId, externalId) -> artifactId mapping twice is a no-op. */
|
|
13
|
+
recordIdentity(producerId: string, externalId: string, artifactId: string): void;
|
|
14
|
+
commitCheckpoint(checkpoint: ProjectionCheckpoint): void;
|
|
15
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TASK_FOCUS_DEFAULT_SCOPE, TASK_FOCUS_MAX_SCOPES, TASK_FOCUS_SCOPE_MAX_LENGTH } from "../constants.ts";
|
|
2
|
+
|
|
1
3
|
export type TaskFocusStatus = "active" | "paused";
|
|
2
4
|
|
|
3
5
|
export interface TaskFocusState {
|
|
@@ -7,37 +9,77 @@ export interface TaskFocusState {
|
|
|
7
9
|
pauseReason?: string;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
export function normalizeFocusScope(scope: string | undefined): string {
|
|
13
|
+
const value = scope ?? TASK_FOCUS_DEFAULT_SCOPE;
|
|
14
|
+
if (value.length === 0 || value.length > TASK_FOCUS_SCOPE_MAX_LENGTH) {
|
|
15
|
+
throw new Error(`task focus scope must be between 1 and ${TASK_FOCUS_SCOPE_MAX_LENGTH} characters`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One Task Focus per scope. A scope defaults to "global" for callers that don't supply
|
|
22
|
+
* a session id (CLI, legacy behavior) but is normally the requesting agent's session id,
|
|
23
|
+
* so concurrent agents each get their own Focus instead of clobbering a shared singleton.
|
|
24
|
+
*/
|
|
10
25
|
export interface TaskFocusStore {
|
|
11
|
-
get(): TaskFocusState | undefined;
|
|
12
|
-
set(taskId: string): TaskFocusState;
|
|
13
|
-
pause(taskId: string, reason?: string): TaskFocusState;
|
|
14
|
-
unpause(taskId: string): TaskFocusState;
|
|
15
|
-
clear(taskId?: string): void;
|
|
26
|
+
get(scope?: string): TaskFocusState | undefined;
|
|
27
|
+
set(taskId: string, scope?: string): TaskFocusState;
|
|
28
|
+
pause(taskId: string, reason?: string, scope?: string): TaskFocusState;
|
|
29
|
+
unpause(taskId: string, scope?: string): TaskFocusState;
|
|
30
|
+
clear(taskId?: string, scope?: string): void;
|
|
31
|
+
/** Clears this task's Focus in every scope (session), not just one — for lifecycle events (e.g. cancel) that are not scoped to a single caller. */
|
|
32
|
+
clearEverywhere(taskId: string): void;
|
|
16
33
|
}
|
|
17
34
|
|
|
18
35
|
export class InMemoryTaskFocusStore implements TaskFocusStore {
|
|
19
|
-
private state
|
|
36
|
+
private readonly state = new Map<string, TaskFocusState>();
|
|
20
37
|
|
|
21
|
-
get(): TaskFocusState | undefined { return this.state; }
|
|
38
|
+
get(scope?: string): TaskFocusState | undefined { return this.state.get(normalizeFocusScope(scope)); }
|
|
39
|
+
|
|
40
|
+
set(taskId: string, scope?: string): TaskFocusState {
|
|
41
|
+
const key = normalizeFocusScope(scope);
|
|
42
|
+
if (!this.state.has(key) && this.state.size >= TASK_FOCUS_MAX_SCOPES) this.evictOldest();
|
|
43
|
+
const focus: TaskFocusState = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
44
|
+
this.state.set(key, focus);
|
|
45
|
+
return focus;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pause(taskId: string, reason?: string, scope?: string): TaskFocusState {
|
|
49
|
+
const key = normalizeFocusScope(scope);
|
|
50
|
+
const current = this.state.get(key);
|
|
51
|
+
if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
52
|
+
const focus: TaskFocusState = { ...current, status: "paused", updatedAt: new Date().toISOString(), ...(reason ? { pauseReason: reason } : {}) };
|
|
53
|
+
this.state.set(key, focus);
|
|
54
|
+
return focus;
|
|
55
|
+
}
|
|
22
56
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
57
|
+
unpause(taskId: string, scope?: string): TaskFocusState {
|
|
58
|
+
const key = normalizeFocusScope(scope);
|
|
59
|
+
const current = this.state.get(key);
|
|
60
|
+
if (current?.taskId !== taskId) throw new Error(`task "${taskId}" is not focused`);
|
|
61
|
+
const focus: TaskFocusState = { taskId, status: "active", updatedAt: new Date().toISOString() };
|
|
62
|
+
this.state.set(key, focus);
|
|
63
|
+
return focus;
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return this.state;
|
|
66
|
+
clear(taskId?: string, scope?: string): void {
|
|
67
|
+
const key = normalizeFocusScope(scope);
|
|
68
|
+
if (taskId === undefined || this.state.get(key)?.taskId === taskId) this.state.delete(key);
|
|
32
69
|
}
|
|
33
70
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
71
|
+
clearEverywhere(taskId: string): void {
|
|
72
|
+
for (const [key, focus] of this.state) {
|
|
73
|
+
if (focus.taskId === taskId) this.state.delete(key);
|
|
74
|
+
}
|
|
38
75
|
}
|
|
39
76
|
|
|
40
|
-
|
|
41
|
-
|
|
77
|
+
private evictOldest(): void {
|
|
78
|
+
let oldestKey: string | undefined;
|
|
79
|
+
let oldestAt: string | undefined;
|
|
80
|
+
for (const [key, focus] of this.state) {
|
|
81
|
+
if (oldestAt === undefined || focus.updatedAt < oldestAt) { oldestKey = key; oldestAt = focus.updatedAt; }
|
|
82
|
+
}
|
|
83
|
+
if (oldestKey !== undefined) this.state.delete(oldestKey);
|
|
42
84
|
}
|
|
43
85
|
}
|