@danypops/papyrus 0.1.0 → 0.2.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.
@@ -1,8 +1,10 @@
1
+ import { TASK_EXECUTION_MAX_DEGREE, TASK_EXECUTION_MAX_EDGES, TASK_EXECUTION_MAX_NODES } from "./constants.ts";
1
2
  import type { Artifact } from "./domain/artifact.ts";
2
3
  import { validateChecklist, type Checklist } from "./domain/checklist.ts";
3
4
  import type { Gate, GateResult } from "./domain/gate.ts";
4
5
  import type { ArtifactStore } from "./ports/artifact-store.ts";
5
6
  import type { GateRunner } from "./ports/gate-runner.ts";
7
+ import { assertDependencyEdgeAllowed } from "./task-execution.ts";
6
8
 
7
9
  export interface TaskFilter {
8
10
  status?: string;
@@ -25,10 +27,17 @@ export interface CreateTaskInput {
25
27
 
26
28
  export type TaskTransition = "start" | "fail" | "retry";
27
29
 
30
+ export interface TaskBlockage {
31
+ artifact: Artifact;
32
+ dependencyIds: string[];
33
+ }
34
+
28
35
  export interface TaskCompletion {
29
36
  artifact: Artifact;
30
37
  gates: GateResult[];
31
38
  completed: boolean;
39
+ started: Artifact[];
40
+ blocked: TaskBlockage[];
32
41
  }
33
42
 
34
43
  export interface TaskNode {
@@ -63,6 +72,9 @@ export class Tasks {
63
72
  }
64
73
 
65
74
  create(input: CreateTaskInput): Artifact {
75
+ if ((input.dependsOn?.length ?? 0) > TASK_EXECUTION_MAX_DEGREE) {
76
+ throw new Error(`task cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
77
+ }
66
78
  if (input.parentId) this.require(input.parentId);
67
79
  for (const dependency of input.dependsOn ?? []) this.require(dependency);
68
80
  const extra: Record<string, unknown> = { ...(input.extra ?? {}) };
@@ -87,7 +99,14 @@ export class Tasks {
87
99
  }
88
100
 
89
101
  graph(filter: TaskFilter = {}): TaskGraph {
90
- const tasks = this.list(filter);
102
+ const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
103
+ if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
104
+ throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
105
+ }
106
+ const tasks = this.list({ ...filter, limit: requestedLimit });
107
+ if (tasks.length > TASK_EXECUTION_MAX_NODES) {
108
+ throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_NODES} nodes`);
109
+ }
91
110
  const byId = new Map(tasks.map((task) => [task.id, task]));
92
111
  const nodes = new Map(tasks.map((task) => [task.id, {
93
112
  task,
@@ -95,7 +114,15 @@ export class Tasks {
95
114
  childIds: [] as string[],
96
115
  dependencyIds: [] as string[],
97
116
  }]));
98
- for (const edge of this.artifacts.relationships({ kind: "task", artifactIds: [...byId.keys()] })) {
117
+ const relationships = this.artifacts.relationships({
118
+ kind: "task",
119
+ artifactIds: [...byId.keys()],
120
+ limit: TASK_EXECUTION_MAX_EDGES + 1,
121
+ });
122
+ if (relationships.length > TASK_EXECUTION_MAX_EDGES) {
123
+ throw new Error(`task execution graph exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
124
+ }
125
+ for (const edge of relationships) {
99
126
  if (!byId.has(edge.from) || !byId.has(edge.to)) continue;
100
127
  const parentId = edge.relation === "contains" ? edge.from : edge.relation === "part_of" ? edge.to : undefined;
101
128
  const childId = edge.relation === "contains" ? edge.to : edge.relation === "part_of" ? edge.from : undefined;
@@ -125,22 +152,30 @@ export class Tasks {
125
152
  const task = this.require(id);
126
153
  const transition = TASK_TRANSITIONS[action];
127
154
  if (!transition.from.includes(task.status)) throw new Error(`cannot ${action} task from ${task.status}`);
155
+ if (action === "start") {
156
+ const blocking = this.dependencyIds(id).filter((dependencyId) => this.require(dependencyId).status !== "done");
157
+ if (blocking.length > 0) throw new Error(`task "${id}" is blocked by dependencies: ${blocking.join(", ")}`);
158
+ }
128
159
  return this.artifacts.setStatus(id, transition.to)!;
129
160
  }
130
161
 
131
162
  complete(id: string): TaskCompletion {
132
163
  const task = this.requireActive(id);
133
164
  const results = this.gates.run(id);
134
- if (results.some((gate) => !gate.passed)) return { artifact: task, gates: results, completed: false };
135
- return { artifact: this.artifacts.setStatus(id, "done")!, gates: results, completed: true };
165
+ if (results.some((gate) => !gate.passed)) {
166
+ return { artifact: task, gates: results, completed: false, started: [], blocked: [] };
167
+ }
168
+ return this.finish(id, results);
136
169
  }
137
170
 
138
171
  async completeAsync(id: string): Promise<TaskCompletion> {
139
172
  this.requireActive(id);
140
173
  const results = await this.gates.runAsync(id);
141
- if (results.some((gate) => !gate.passed)) return { artifact: this.require(id), gates: results, completed: false };
174
+ if (results.some((gate) => !gate.passed)) {
175
+ return { artifact: this.require(id), gates: results, completed: false, started: [], blocked: [] };
176
+ }
142
177
  const current = this.requireActive(id);
143
- return { artifact: this.artifacts.setStatus(current.id, "done")!, gates: results, completed: true };
178
+ return this.finish(current.id, results);
144
179
  }
145
180
 
146
181
  runGates(id: string): Promise<GateResult[]> {
@@ -156,6 +191,17 @@ export class Tasks {
156
191
  depend(id: string, dependencyId: string): Artifact {
157
192
  this.require(id);
158
193
  this.require(dependencyId);
194
+ const graph = this.graph();
195
+ assertDependencyEdgeAllowed(graph, id, dependencyId);
196
+ const node = graph.nodes.find((entry) => entry.task.id === id)!;
197
+ if (node.dependencyIds.includes(dependencyId)) return this.show(id);
198
+ if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
199
+ throw new Error(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
200
+ }
201
+ const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
202
+ if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
203
+ throw new Error(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
204
+ }
159
205
  this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId });
160
206
  return this.show(id);
161
207
  }
@@ -168,6 +214,52 @@ export class Tasks {
168
214
  return this.show(parentId);
169
215
  }
170
216
 
217
+ private relationships(id: string) {
218
+ const relationships = this.artifacts.relationships({
219
+ kind: "task",
220
+ artifactIds: [id],
221
+ limit: TASK_EXECUTION_MAX_EDGES + 1,
222
+ });
223
+ if (relationships.length > TASK_EXECUTION_MAX_EDGES) {
224
+ throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_EDGES} relationships`);
225
+ }
226
+ return relationships;
227
+ }
228
+
229
+ private dependencyIds(id: string): string[] {
230
+ const ids = this.relationships(id)
231
+ .filter((edge) => edge.relation === "depends_on" && edge.from === id)
232
+ .map((edge) => edge.to);
233
+ if (ids.length > TASK_EXECUTION_MAX_DEGREE) {
234
+ throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
235
+ }
236
+ return ids;
237
+ }
238
+
239
+ private finish(id: string, gates: GateResult[]): TaskCompletion {
240
+ const successorIds = this.relationships(id)
241
+ .filter((edge) => edge.relation === "depends_on" && edge.to === id)
242
+ .map((edge) => edge.from);
243
+ if (successorIds.length > TASK_EXECUTION_MAX_DEGREE) {
244
+ throw new Error(`task "${id}" exceeds ${TASK_EXECUTION_MAX_DEGREE} successors`);
245
+ }
246
+ const artifact = this.artifacts.setStatus(id, "done")!;
247
+ const started: Artifact[] = [];
248
+ const blocked: TaskBlockage[] = [];
249
+ for (const successorId of successorIds) {
250
+ const successor = this.require(successorId);
251
+ if (successor.status !== "pending") continue;
252
+ const dependencyIds = this.dependencyIds(successorId)
253
+ .filter((dependencyId) => this.require(dependencyId).status !== "done");
254
+ if (dependencyIds.length > 0) {
255
+ blocked.push({ artifact: successor, dependencyIds });
256
+ continue;
257
+ }
258
+ started.push(this.artifacts.setStatus(successorId, "active")!);
259
+ }
260
+ return { artifact, gates, completed: true, started, blocked };
261
+ }
262
+
171
263
  private requireActive(id: string): Artifact {
172
264
  const task = this.require(id);
173
265
  if (task.status !== "active") throw new Error(`cannot complete task from ${task.status}`);
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ function packageVersion(): string {
4
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown;
5
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
6
+ throw new Error("Papyrus package manifest must be an object");
7
+ }
8
+ const version = (manifest as Record<string, unknown>)["version"];
9
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
10
+ throw new Error("Papyrus package manifest has an invalid version");
11
+ }
12
+ return version;
13
+ }
14
+
15
+ /** Runtime package version; package.json is the single release source of truth. */
16
+ export const VERSION = packageVersion();