@danypops/papyrus 0.17.2 → 0.19.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 +23 -0
- package/extension/src/domain-tools.ts +105 -6
- package/package.json +1 -1
- package/src/adapters/sqlite-artifact-store.ts +37 -1
- package/src/adapters/sqlite-discussion-round-store.ts +61 -0
- package/src/cli.ts +150 -1
- package/src/constants.ts +31 -1
- package/src/daemon.ts +9 -0
- package/src/db.ts +86 -5
- package/src/discussion-service.ts +159 -0
- package/src/domain/artifact-event.ts +1 -1
- package/src/domain/artifact-trash.ts +29 -0
- package/src/domain/artifact.ts +2 -0
- package/src/domain/discussion.ts +107 -0
- package/src/modules/discuss.ts +87 -0
- package/src/ops.ts +106 -1
- package/src/ports/artifact-store.ts +9 -0
- package/src/ports/discussion-round-store.ts +8 -0
- package/src/service.ts +24 -0
- package/src/task-service.ts +27 -0
package/src/ops.ts
CHANGED
|
@@ -6,8 +6,10 @@ import { createRequire } from "node:module";
|
|
|
6
6
|
import { exec } from "node:child_process";
|
|
7
7
|
import type { Db } from "./db.ts";
|
|
8
8
|
import { inTransaction } from "./db.ts";
|
|
9
|
-
import { DEFAULT_STATUS_BY_KIND } from "./constants.ts";
|
|
9
|
+
import { ARTIFACT_TRASH_RETENTION_MS, DEFAULT_STATUS_BY_KIND } from "./constants.ts";
|
|
10
10
|
import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
|
|
11
|
+
import type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
|
|
12
|
+
export type { ArtifactTrashRecord } from "./domain/artifact-trash.ts";
|
|
11
13
|
import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
|
|
12
14
|
import {
|
|
13
15
|
normalizeArtifactEventQuery,
|
|
@@ -288,6 +290,7 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
|
|
|
288
290
|
let sql = "SELECT * FROM artifacts";
|
|
289
291
|
const conditions: string[] = [];
|
|
290
292
|
const params: unknown[] = [];
|
|
293
|
+
if (!filter.includeTrashed) conditions.push("id NOT IN (SELECT artifact_id FROM artifact_trash)");
|
|
291
294
|
if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
|
|
292
295
|
if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
|
|
293
296
|
if (filter.statuses) {
|
|
@@ -318,6 +321,108 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
|
|
|
318
321
|
return rows.map(rowToArtifact);
|
|
319
322
|
}
|
|
320
323
|
|
|
324
|
+
function rowToTrashRecord(row: Record<string, unknown>): ArtifactTrashRecord {
|
|
325
|
+
return {
|
|
326
|
+
artifactId: row["artifact_id"] as string,
|
|
327
|
+
trashedAt: row["trashed_at"] as string,
|
|
328
|
+
purgeAfter: row["purge_after"] as string,
|
|
329
|
+
...(row["reason"] == null ? {} : { reason: row["reason"] as string }),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function getArtifactTrash(db: Db, id: string): ArtifactTrashRecord | null {
|
|
334
|
+
const row = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash WHERE artifact_id = ?").get(id) as Record<string, unknown> | null;
|
|
335
|
+
return row ? rowToTrashRecord(row) : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function listArtifactTrash(db: Db): ArtifactTrashRecord[] {
|
|
339
|
+
const rows = db.prepare("SELECT artifact_id, trashed_at, purge_after, reason FROM artifact_trash ORDER BY purge_after ASC").all() as Record<string, unknown>[];
|
|
340
|
+
return rows.map(rowToTrashRecord);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Moves an artifact to the trash: it becomes ineligible for purge_after ms (see
|
|
345
|
+
* ARTIFACT_TRASH_RETENTION_MS), immediately excluded from queryArtifacts by default, still
|
|
346
|
+
* directly reachable via getArtifact, and fully restorable via restoreArtifact until the
|
|
347
|
+
* daemon's periodic sweep (purgeDueArtifacts) actually deletes it. Re-removing an
|
|
348
|
+
* already-trashed artifact resets its clock rather than erroring -- the same "most recent
|
|
349
|
+
* intent wins" semantics as registerSessionIdentity's rotation.
|
|
350
|
+
*
|
|
351
|
+
* Refuses to trash a Task that is the live Task Focus in any scope: Focus is active,
|
|
352
|
+
* behavior-affecting state, and trashing out from under it would silently discard work a
|
|
353
|
+
* caller is not necessarily looking at right now. No other kind has an analogous "currently
|
|
354
|
+
* in use" signal to check.
|
|
355
|
+
*/
|
|
356
|
+
export function trashArtifact(db: Db, id: string, options?: { reason?: string; now?: () => string; context?: ArtifactEventContext }): ArtifactTrashRecord {
|
|
357
|
+
const artifact = getArtifact(db, id);
|
|
358
|
+
if (!artifact) throw new Error(`artifact "${id}" not found`);
|
|
359
|
+
const focusedScope = db.prepare("SELECT scope FROM task_focus WHERE task_id = ? LIMIT 1").get(id) as { scope: string } | null;
|
|
360
|
+
if (focusedScope) throw new Error(`artifact "${id}" is the active Task Focus in scope "${focusedScope.scope}"; clear focus before removing it`);
|
|
361
|
+
const now = options?.now ?? (() => new Date().toISOString());
|
|
362
|
+
const trashedAt = now();
|
|
363
|
+
const purgeAfter = new Date(new Date(trashedAt).getTime() + ARTIFACT_TRASH_RETENTION_MS).toISOString();
|
|
364
|
+
const record: ArtifactTrashRecord = { artifactId: id, trashedAt, purgeAfter, ...(options?.reason ? { reason: options.reason } : {}) };
|
|
365
|
+
inTransaction(db, () => {
|
|
366
|
+
db.prepare(`
|
|
367
|
+
INSERT INTO artifact_trash (artifact_id, trashed_at, purge_after, reason) VALUES (?, ?, ?, ?)
|
|
368
|
+
ON CONFLICT (artifact_id) DO UPDATE SET trashed_at = excluded.trashed_at, purge_after = excluded.purge_after, reason = excluded.reason
|
|
369
|
+
`).run(record.artifactId, record.trashedAt, record.purgeAfter, record.reason ?? null);
|
|
370
|
+
appendArtifactEvent(db, { artifactId: id, type: "trashed", ...(options?.context ?? {}) });
|
|
371
|
+
});
|
|
372
|
+
return record;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Idempotent: restoring an artifact that is not currently trashed is a real no-op, not an error -- mirrors releaseSessionIdentity's idempotence. */
|
|
376
|
+
export function restoreArtifact(db: Db, id: string, context?: ArtifactEventContext): { restored: boolean } {
|
|
377
|
+
const wasTrashed = getArtifactTrash(db, id) !== null;
|
|
378
|
+
if (!wasTrashed) return { restored: false };
|
|
379
|
+
inTransaction(db, () => {
|
|
380
|
+
db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
|
|
381
|
+
appendArtifactEvent(db, { artifactId: id, type: "restored", ...(context ?? {}) });
|
|
382
|
+
});
|
|
383
|
+
return { restored: true };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Real, cascading, irreversible deletion of every artifact whose purge_after has passed.
|
|
388
|
+
* Never called with anything but the real current time in production -- see daemon.ts's
|
|
389
|
+
* periodic sweep; a directly-injected `now` exists only so tests can exercise this without
|
|
390
|
+
* waiting out ARTIFACT_TRASH_RETENTION_MS for real.
|
|
391
|
+
*
|
|
392
|
+
* Deletes, in FK-safe order, every row across every table that can reference artifacts(id)
|
|
393
|
+
* (see the grep-verified list in domain/artifact-trash.ts's design comment): edges (both
|
|
394
|
+
* directions), task_focus, task_scopes, task_views (by root_task_id), graph_projection_
|
|
395
|
+
* identities, artifact_scopes, then task_events and artifact_events -- the latter two
|
|
396
|
+
* succeed only because the artifact_trash row placed here by trashArtifact still exists
|
|
397
|
+
* with an elapsed purge_after, which is exactly what db.ts's task_events_no_delete /
|
|
398
|
+
* artifact_events_no_delete trigger carve-outs check themselves. Only THEN artifact_trash's
|
|
399
|
+
* own row (it is itself a child of artifacts via a real FK, so it must go before artifacts,
|
|
400
|
+
* but only after the event tables that depend on its continued presence), and artifacts
|
|
401
|
+
* itself last of all. One artifact at a time in its own transaction, so one failure never
|
|
402
|
+
* blocks any other due artifact.
|
|
403
|
+
*/
|
|
404
|
+
export function purgeDueArtifacts(db: Db, now: () => string = () => new Date().toISOString()): number {
|
|
405
|
+
const nowIso = now();
|
|
406
|
+
const due = (db.prepare("SELECT artifact_id FROM artifact_trash WHERE purge_after <= ?").all(nowIso) as Array<{ artifact_id: string }>).map((row) => row.artifact_id);
|
|
407
|
+
let purged = 0;
|
|
408
|
+
for (const id of due) {
|
|
409
|
+
inTransaction(db, () => {
|
|
410
|
+
db.prepare("DELETE FROM edges WHERE from_id = ? OR to_id = ?").run(id, id);
|
|
411
|
+
db.prepare("DELETE FROM task_focus WHERE task_id = ?").run(id);
|
|
412
|
+
db.prepare("DELETE FROM task_scopes WHERE task_id = ?").run(id);
|
|
413
|
+
db.prepare("DELETE FROM task_views WHERE root_task_id = ?").run(id);
|
|
414
|
+
db.prepare("DELETE FROM graph_projection_identities WHERE artifact_id = ?").run(id);
|
|
415
|
+
db.prepare("DELETE FROM artifact_scopes WHERE artifact_id = ?").run(id);
|
|
416
|
+
db.prepare("DELETE FROM task_events WHERE task_id = ?").run(id);
|
|
417
|
+
db.prepare("DELETE FROM artifact_events WHERE artifact_id = ?").run(id);
|
|
418
|
+
db.prepare("DELETE FROM artifact_trash WHERE artifact_id = ?").run(id);
|
|
419
|
+
db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
|
|
420
|
+
});
|
|
421
|
+
purged += 1;
|
|
422
|
+
}
|
|
423
|
+
return purged;
|
|
424
|
+
}
|
|
425
|
+
|
|
321
426
|
export function linkArtifacts(db: Db, fromId: string, relation: string, toId: string, context?: ArtifactEventContext): void {
|
|
322
427
|
const fromArt = getArtifact(db, fromId);
|
|
323
428
|
const toArt = getArtifact(db, toId);
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
UpdateArtifactInput,
|
|
10
10
|
} from "../domain/artifact.ts";
|
|
11
11
|
import type { ArtifactEventContext, ArtifactEventPage, ArtifactEventQuery } from "../domain/artifact-event.ts";
|
|
12
|
+
import type { ArtifactTrashRecord } from "../domain/artifact-trash.ts";
|
|
12
13
|
|
|
13
14
|
export interface ArtifactStore {
|
|
14
15
|
create(input: CreateArtifactInput, context?: ArtifactEventContext): Artifact;
|
|
@@ -23,4 +24,12 @@ export interface ArtifactStore {
|
|
|
23
24
|
relationships(filter?: RelationshipQuery): ArtifactEdge[];
|
|
24
25
|
/** Bounded query over the generic mutation event log shared by every kind. */
|
|
25
26
|
events(query: ArtifactEventQuery): ArtifactEventPage;
|
|
27
|
+
/** See domain/artifact-trash.ts. Moves an artifact to the trash; throws if it does not exist or is the live Task Focus in any scope. */
|
|
28
|
+
trash(id: string, options?: { reason?: string; context?: ArtifactEventContext }): ArtifactTrashRecord;
|
|
29
|
+
/** Idempotent: restoring an artifact that is not currently trashed is a real no-op. */
|
|
30
|
+
restore(id: string, context?: ArtifactEventContext): { restored: boolean };
|
|
31
|
+
trashStatus(id: string): ArtifactTrashRecord | null;
|
|
32
|
+
listTrash(): ArtifactTrashRecord[];
|
|
33
|
+
/** Real, cascading, irreversible deletion of every artifact past its purge deadline; returns how many were purged. */
|
|
34
|
+
purgeDueTrash(): number;
|
|
26
35
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppendDiscussionRound, DiscussionRound, DiscussionRoundQuery } from "../domain/discussion.ts";
|
|
2
|
+
|
|
3
|
+
/** Persistence port for a Discussion's append-only rounds (see domain/discussion.ts). */
|
|
4
|
+
export interface DiscussionRoundStore {
|
|
5
|
+
append(round: AppendDiscussionRound, occurredAt: string): DiscussionRound;
|
|
6
|
+
list(query: DiscussionRoundQuery): DiscussionRound[];
|
|
7
|
+
count(discussionId: string): number;
|
|
8
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -34,7 +34,10 @@ import { notesOperations, NOTES_OPERATION_NAMES } from "./modules/notes.ts";
|
|
|
34
34
|
import { rulesOperations, RULES_OPERATION_NAMES } from "./modules/rules.ts";
|
|
35
35
|
import { skillsOperations, SKILLS_OPERATION_NAMES } from "./modules/skills.ts";
|
|
36
36
|
import { sessionIdentityOperations, SESSION_IDENTITY_OPERATION_NAMES } from "./modules/session-identity.ts";
|
|
37
|
+
import { discussOperations, DISCUSS_OPERATION_NAMES } from "./modules/discuss.ts";
|
|
37
38
|
import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
39
|
+
import { Discussions } from "./discussion-service.ts";
|
|
40
|
+
import { SQLiteDiscussionRoundStore } from "./adapters/sqlite-discussion-round-store.ts";
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Operations with no registered module: the generic, cross-cutting kernel surface
|
|
@@ -49,6 +52,7 @@ import { tasksOperations, TASKS_OPERATION_NAMES } from "./modules/tasks.ts";
|
|
|
49
52
|
*/
|
|
50
53
|
const COMPOSITION_ROOT_OPERATION_NAMES = [
|
|
51
54
|
"system.migrate", "artifact.create", "artifact.query", "artifact.show",
|
|
55
|
+
"artifact.remove", "artifact.restore", "artifact.trash_status", "artifact.trash_list",
|
|
52
56
|
"graph.link", "graph.unlink", "graph.tree", "graph.status", "graph.history", "gates.run",
|
|
53
57
|
"rules.injectable", "skills.instantiate",
|
|
54
58
|
] as const;
|
|
@@ -72,6 +76,7 @@ export const EXPECTED_OPERATION_NAMES = [
|
|
|
72
76
|
...GRAPH_PROJECTION_OPERATION_NAMES,
|
|
73
77
|
...LOGS_OPERATION_NAMES,
|
|
74
78
|
...SESSION_IDENTITY_OPERATION_NAMES,
|
|
79
|
+
...DISCUSS_OPERATION_NAMES,
|
|
75
80
|
] as const;
|
|
76
81
|
|
|
77
82
|
export type OperationName = typeof EXPECTED_OPERATION_NAMES[number];
|
|
@@ -173,6 +178,8 @@ export interface PapyrusService {
|
|
|
173
178
|
optimize(): void;
|
|
174
179
|
/** Time-based Task Focus reclamation (see Tasks.reapStaleFocus); returns how many rows were removed, for daemon logging. */
|
|
175
180
|
reapStaleFocus(): number;
|
|
181
|
+
/** Real, cascading deletion of every artifact past its trash purge deadline (see domain/artifact-trash.ts); returns how many were purged, for daemon logging. */
|
|
182
|
+
purgeDueTrash(): number;
|
|
176
183
|
close(): void;
|
|
177
184
|
}
|
|
178
185
|
|
|
@@ -241,6 +248,10 @@ function handlers(
|
|
|
241
248
|
depth: optionalNumber(input, "depth"),
|
|
242
249
|
maxNodes: optionalNumber(input, "max_nodes") ?? optionalNumber(input, "maxNodes"),
|
|
243
250
|
}),
|
|
251
|
+
"artifact.remove": (input) => artifacts.trash(string(input, "id"), { reason: optionalString(input, "reason"), context: eventContext(input) }),
|
|
252
|
+
"artifact.restore": (input) => artifacts.restore(string(input, "id"), eventContext(input)),
|
|
253
|
+
"artifact.trash_status": (input) => artifacts.trashStatus(string(input, "id")),
|
|
254
|
+
"artifact.trash_list": () => artifacts.listTrash(),
|
|
244
255
|
"graph.link": (input) => {
|
|
245
256
|
const from = string(input, "from");
|
|
246
257
|
const relation = string(input, "relation");
|
|
@@ -376,6 +387,16 @@ function handlers(
|
|
|
376
387
|
"logs.query": forwardToModule("logs.query"),
|
|
377
388
|
"session.register": forwardToModule("session.register"),
|
|
378
389
|
"session.release": forwardToModule("session.release"),
|
|
390
|
+
"discuss.open": forwardToModule("discuss.open"),
|
|
391
|
+
"discuss.reply": forwardToModule("discuss.reply"),
|
|
392
|
+
"discuss.defer": forwardToModule("discuss.defer"),
|
|
393
|
+
"discuss.resume": forwardToModule("discuss.resume"),
|
|
394
|
+
"discuss.settle": forwardToModule("discuss.settle"),
|
|
395
|
+
"discuss.block": forwardToModule("discuss.block"),
|
|
396
|
+
"discuss.unblock": forwardToModule("discuss.unblock"),
|
|
397
|
+
"discuss.show": forwardToModule("discuss.show"),
|
|
398
|
+
"discuss.rounds": forwardToModule("discuss.rounds"),
|
|
399
|
+
"discuss.list": forwardToModule("discuss.list"),
|
|
379
400
|
};
|
|
380
401
|
}
|
|
381
402
|
|
|
@@ -392,11 +413,13 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
392
413
|
const artifactScopes = new SQLiteArtifactScopeStore(db);
|
|
393
414
|
const logs = new Logs(new SQLiteLogStore(db));
|
|
394
415
|
const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
|
|
416
|
+
const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
|
|
395
417
|
const authority = createAuthorityRegistry();
|
|
396
418
|
const moduleRegistry = new OperationRegistry();
|
|
397
419
|
moduleRegistry.registerAll(notesOperations(notes));
|
|
398
420
|
moduleRegistry.registerAll(logsOperations(logs));
|
|
399
421
|
moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
|
|
422
|
+
moduleRegistry.registerAll(discussOperations(discussions));
|
|
400
423
|
moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
|
|
401
424
|
moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority));
|
|
402
425
|
moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes));
|
|
@@ -421,6 +444,7 @@ export function createPapyrusService(path: string): PapyrusService {
|
|
|
421
444
|
checkpoint: () => { db.exec("PRAGMA wal_checkpoint(PASSIVE)"); },
|
|
422
445
|
optimize: () => { db.exec("PRAGMA optimize"); },
|
|
423
446
|
reapStaleFocus: () => tasks.reapStaleFocus(),
|
|
447
|
+
purgeDueTrash: () => artifacts.purgeDueTrash(),
|
|
424
448
|
close: () => {
|
|
425
449
|
db.exec("PRAGMA optimize");
|
|
426
450
|
db.close();
|
package/src/task-service.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./constants.ts";
|
|
12
12
|
import type { Artifact } from "./domain/artifact.ts";
|
|
13
13
|
import { checklistEntries, validateChecklist, type Checklist, type ProofReference } from "./domain/checklist.ts";
|
|
14
|
+
import { isDiscussionArtifact, readDiscussionExtra } from "./domain/discussion.ts";
|
|
14
15
|
import type { Gate, GateResult } from "./domain/gate.ts";
|
|
15
16
|
import type { AppendTaskEvent, TaskEventContext, TaskHistoryPage, TaskHistoryQuery, TaskLifecycleStatus } from "./domain/task-event.ts";
|
|
16
17
|
import { normalizeProjectRoot, taskScopeLabel, type TaskScopeSource, type TaskViewMode, type TaskViewSelection } from "./domain/task-scope.ts";
|
|
@@ -419,6 +420,7 @@ export class Tasks {
|
|
|
419
420
|
|
|
420
421
|
complete(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): TaskCompletion {
|
|
421
422
|
const task = this.requireReview(id);
|
|
423
|
+
this.requireNotBlocked(id);
|
|
422
424
|
const attemptId = crypto.randomUUID();
|
|
423
425
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
424
426
|
const checklist = this.reviewChecklist(task);
|
|
@@ -428,6 +430,7 @@ export class Tasks {
|
|
|
428
430
|
|
|
429
431
|
async completeAsync(id: string, context: TaskEventContext = {}, options: TaskCompletionOptions = {}): Promise<TaskCompletion> {
|
|
430
432
|
const task = this.requireReview(id);
|
|
433
|
+
this.requireNotBlocked(id);
|
|
431
434
|
const attemptId = crypto.randomUUID();
|
|
432
435
|
this.events.atomic(() => this.appendEvent({ taskId: id, type: "completion_attempted", fromStatus: "review", toStatus: "review", attemptId }, context));
|
|
433
436
|
const checklist = this.reviewChecklist(task);
|
|
@@ -678,4 +681,28 @@ export class Tasks {
|
|
|
678
681
|
if (task.status !== "review") throw new Error(`cannot complete task from ${task.status}`);
|
|
679
682
|
return task;
|
|
680
683
|
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Discuss's forcing behavior (see domain/discussion.ts): an active Discussion doc that
|
|
687
|
+
* `blocks` this task refuses its completion until settled or deferred. A discussion whose
|
|
688
|
+
* extra.discussion shape is missing or corrupt is treated as non-blocking rather than
|
|
689
|
+
* crashing completion -- the same fail-open posture Task Focus's opt-in armor uses for an
|
|
690
|
+
* unrecognized shape.
|
|
691
|
+
*/
|
|
692
|
+
private blockingDiscussions(id: string): Artifact[] {
|
|
693
|
+
return this.artifacts.relationships({ artifactIds: [id] })
|
|
694
|
+
.filter((edge) => edge.relation === "blocks" && edge.to === id)
|
|
695
|
+
.map((edge) => this.artifacts.get(edge.from))
|
|
696
|
+
.filter((source): source is Artifact => source !== null && isDiscussionArtifact(source))
|
|
697
|
+
.filter((discussion) => {
|
|
698
|
+
try { return readDiscussionExtra(discussion.extra).state === "active"; } catch { return false; }
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private requireNotBlocked(id: string): void {
|
|
703
|
+
const blockers = this.blockingDiscussions(id);
|
|
704
|
+
if (blockers.length > 0) {
|
|
705
|
+
throw new Error(`task "${id}" is blocked by ${blockers.length} active Discussion(s): ${blockers.map((discussion) => discussion.id).join(", ")}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
681
708
|
}
|