@sgtbeatdown/pi-tasks 0.1.0 → 0.1.2

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/src/metrics.ts ADDED
@@ -0,0 +1,288 @@
1
+ import { readTaskStoreArtifacts } from "./task-artifacts";
2
+ import type { TaskStoreArtifactRecord, TaskStoreDiagnostic } from "./task-artifacts";
3
+
4
+ const LARGEST_LIST_LIMIT = 10;
5
+
6
+ export interface TaskMetricCount {
7
+ readonly taskId: string;
8
+ readonly charCount: number;
9
+ }
10
+
11
+ export type TaskMetricsDiagnostic = TaskStoreDiagnostic;
12
+
13
+ export interface TaskGroupingMetrics {
14
+ readonly byPhaseId: Readonly<Record<string, number>>;
15
+ readonly byIssueId: Readonly<Record<string, number>>;
16
+ readonly byRootCause: Readonly<Record<string, number>>;
17
+ readonly loopsPerIssueId: Readonly<Record<string, number>>;
18
+ }
19
+
20
+ export interface TaskStoreMetricsReport {
21
+ readonly schemaVersion: 1;
22
+ readonly tasksRoot: string;
23
+ readonly totalTaskRecords: number;
24
+ readonly statusCounts: Readonly<Record<string, number>>;
25
+ readonly preflight: {
26
+ readonly rejectedCount: number;
27
+ readonly failedChecksByName: Readonly<Record<string, number>>;
28
+ readonly unavailableChecksByName: Readonly<Record<string, number>>;
29
+ };
30
+ readonly characterSizes: {
31
+ readonly totalTaskTextChars: number;
32
+ readonly totalAssignmentChars: number;
33
+ readonly totalInstructionChars: number;
34
+ readonly totalResultSummaryChars: number;
35
+ readonly totalSerializedResultPayloadChars: number;
36
+ };
37
+ readonly largest: {
38
+ readonly taskPrompts: readonly TaskMetricCount[];
39
+ readonly results: readonly TaskMetricCount[];
40
+ };
41
+ readonly grouping: TaskGroupingMetrics;
42
+ readonly verification: {
43
+ readonly entryCount: number;
44
+ readonly commandCounts: Readonly<Record<string, number>>;
45
+ readonly statusCounts: Readonly<Record<string, number>>;
46
+ };
47
+ readonly diagnostics: readonly TaskMetricsDiagnostic[];
48
+ }
49
+
50
+ interface MutableTotals {
51
+ totalTaskRecords: number;
52
+ preflightRejectedCount: number;
53
+ totalTaskTextChars: number;
54
+ totalAssignmentChars: number;
55
+ totalInstructionChars: number;
56
+ totalResultSummaryChars: number;
57
+ totalSerializedResultPayloadChars: number;
58
+ verificationEntryCount: number;
59
+ }
60
+
61
+ export async function analyzeTaskStoreMetrics(tasksRoot: string): Promise<TaskStoreMetricsReport> {
62
+ const artifactRead = await readTaskStoreArtifacts(tasksRoot);
63
+ const diagnostics: TaskMetricsDiagnostic[] = [...artifactRead.diagnostics];
64
+ const taskPrompts: TaskMetricCount[] = [];
65
+ const results: TaskMetricCount[] = [];
66
+ const statusCounts: Record<string, number> = {};
67
+ const failedChecksByName: Record<string, number> = {};
68
+ const unavailableChecksByName: Record<string, number> = {};
69
+ const byPhaseId: Record<string, number> = {};
70
+ const byIssueId: Record<string, number> = {};
71
+ const byRootCause: Record<string, number> = {};
72
+ const commandCounts: Record<string, number> = {};
73
+ const verificationStatusCounts: Record<string, number> = {};
74
+ const totals: MutableTotals = {
75
+ totalTaskRecords: 0,
76
+ preflightRejectedCount: 0,
77
+ totalTaskTextChars: 0,
78
+ totalAssignmentChars: 0,
79
+ totalInstructionChars: 0,
80
+ totalResultSummaryChars: 0,
81
+ totalSerializedResultPayloadChars: 0,
82
+ verificationEntryCount: 0,
83
+ };
84
+ const analysis: TaskAnalysis = {
85
+ totals,
86
+ statusCounts,
87
+ failedChecksByName,
88
+ unavailableChecksByName,
89
+ byPhaseId,
90
+ byIssueId,
91
+ byRootCause,
92
+ commandCounts,
93
+ verificationStatusCounts,
94
+ taskPrompts,
95
+ results,
96
+ };
97
+
98
+ for (const record of artifactRead.records) {
99
+ analyzeTaskArtifact(record, analysis);
100
+ }
101
+
102
+ return buildReport(tasksRoot, totals, statusCounts, failedChecksByName, unavailableChecksByName, byPhaseId, byIssueId, byRootCause, commandCounts, verificationStatusCounts, taskPrompts, results, diagnostics);
103
+ }
104
+
105
+ interface TaskAnalysis {
106
+ readonly totals: MutableTotals;
107
+ readonly statusCounts: Record<string, number>;
108
+ readonly failedChecksByName: Record<string, number>;
109
+ readonly unavailableChecksByName: Record<string, number>;
110
+ readonly byPhaseId: Record<string, number>;
111
+ readonly byIssueId: Record<string, number>;
112
+ readonly byRootCause: Record<string, number>;
113
+ readonly commandCounts: Record<string, number>;
114
+ readonly verificationStatusCounts: Record<string, number>;
115
+ readonly taskPrompts: TaskMetricCount[];
116
+ readonly results: TaskMetricCount[];
117
+ }
118
+
119
+ function analyzeTaskArtifact(record: TaskStoreArtifactRecord, analysis: TaskAnalysis): void {
120
+ const task = record.task;
121
+ const taskId = typeof task.id === "string" && task.id.length > 0 ? task.id : record.taskDirectoryName;
122
+ analysis.totals.totalTaskRecords += 1;
123
+
124
+ const status = typeof task.status === "string" && task.status.length > 0 ? task.status : "unknown";
125
+ increment(analysis.statusCounts, status);
126
+
127
+ const taskText = typeof task.taskText === "string" ? task.taskText : "";
128
+ analysis.totals.totalTaskTextChars += taskText.length;
129
+ analysis.taskPrompts.push({ taskId, charCount: taskText.length });
130
+
131
+ analyzeMetadata(task.metadata, analysis);
132
+ analyzePreflight(task, status, analysis);
133
+ analyzeAssignment(record.assignment, analysis);
134
+ analyzeResult(record.result, taskId, analysis);
135
+ }
136
+
137
+ function analyzeMetadata(metadata: unknown, analysis: TaskAnalysis): void {
138
+ if (!isRecord(metadata)) {
139
+ return;
140
+ }
141
+ incrementStringField(analysis.byPhaseId, metadata.phaseId);
142
+ incrementStringField(analysis.byIssueId, metadata.issueId);
143
+ incrementStringField(analysis.byRootCause, metadata.rootCause);
144
+ }
145
+
146
+ function analyzePreflight(task: Record<string, unknown>, status: string, analysis: TaskAnalysis): void {
147
+ const preflight = isRecord(task.preflight) ? task.preflight : undefined;
148
+ const error = isRecord(task.error) ? task.error : undefined;
149
+ if (status === "rejected" && (error?.code === "preflight_failed" || preflight?.ok === false)) {
150
+ analysis.totals.preflightRejectedCount += 1;
151
+ }
152
+
153
+ if (!preflight || !Array.isArray(preflight.checks)) {
154
+ return;
155
+ }
156
+ for (const check of preflight.checks) {
157
+ if (!isRecord(check) || typeof check.name !== "string") {
158
+ continue;
159
+ }
160
+ if (check.status === "failed") {
161
+ increment(analysis.failedChecksByName, check.name);
162
+ } else if (check.status === "unavailable") {
163
+ increment(analysis.unavailableChecksByName, check.name);
164
+ }
165
+ }
166
+ }
167
+
168
+ function analyzeAssignment(assignment: unknown | undefined, analysis: TaskAnalysis): void {
169
+ if (assignment === undefined) {
170
+ return;
171
+ }
172
+ const serializedAssignment = JSON.stringify(assignment);
173
+ analysis.totals.totalAssignmentChars += typeof assignment === "string" ? assignment.length : serializedAssignment?.length ?? 0;
174
+ if (isRecord(assignment) && typeof assignment.instructions === "string") {
175
+ analysis.totals.totalInstructionChars += assignment.instructions.length;
176
+ }
177
+ }
178
+
179
+ function analyzeResult(
180
+ storedResult: Record<string, unknown> | undefined,
181
+ taskId: string,
182
+ analysis: TaskAnalysis,
183
+ ): void {
184
+ if (!storedResult) {
185
+ return;
186
+ }
187
+ const summaryChars = typeof storedResult.summary === "string" ? storedResult.summary.length : 0;
188
+ const payloadChars = "result" in storedResult ? (JSON.stringify(storedResult.result)?.length ?? 0) : 0;
189
+ analysis.totals.totalResultSummaryChars += summaryChars;
190
+ analysis.totals.totalSerializedResultPayloadChars += payloadChars;
191
+ analysis.results.push({ taskId, charCount: summaryChars + payloadChars });
192
+
193
+ if (!isRecord(storedResult.result) || !Array.isArray(storedResult.result.verification)) {
194
+ return;
195
+ }
196
+ for (const entry of storedResult.result.verification) {
197
+ analysis.totals.verificationEntryCount += 1;
198
+ if (!isRecord(entry)) {
199
+ continue;
200
+ }
201
+ if (typeof entry.command === "string" && entry.command.length > 0) {
202
+ increment(analysis.commandCounts, entry.command);
203
+ }
204
+ if (typeof entry.status === "string" && entry.status.length > 0) {
205
+ increment(analysis.verificationStatusCounts, entry.status);
206
+ }
207
+ }
208
+ }
209
+
210
+ function buildReport(
211
+ tasksRoot: string,
212
+ totals: MutableTotals,
213
+ statusCounts: Record<string, number>,
214
+ failedChecksByName: Record<string, number>,
215
+ unavailableChecksByName: Record<string, number>,
216
+ byPhaseId: Record<string, number>,
217
+ byIssueId: Record<string, number>,
218
+ byRootCause: Record<string, number>,
219
+ commandCounts: Record<string, number>,
220
+ verificationStatusCounts: Record<string, number>,
221
+ taskPrompts: TaskMetricCount[],
222
+ results: TaskMetricCount[],
223
+ diagnostics: TaskMetricsDiagnostic[],
224
+ ): TaskStoreMetricsReport {
225
+ const issueCounts = sortedCounts(byIssueId);
226
+ return {
227
+ schemaVersion: 1,
228
+ tasksRoot,
229
+ totalTaskRecords: totals.totalTaskRecords,
230
+ statusCounts: sortedCounts(statusCounts),
231
+ preflight: {
232
+ rejectedCount: totals.preflightRejectedCount,
233
+ failedChecksByName: sortedCounts(failedChecksByName),
234
+ unavailableChecksByName: sortedCounts(unavailableChecksByName),
235
+ },
236
+ characterSizes: {
237
+ totalTaskTextChars: totals.totalTaskTextChars,
238
+ totalAssignmentChars: totals.totalAssignmentChars,
239
+ totalInstructionChars: totals.totalInstructionChars,
240
+ totalResultSummaryChars: totals.totalResultSummaryChars,
241
+ totalSerializedResultPayloadChars: totals.totalSerializedResultPayloadChars,
242
+ },
243
+ largest: {
244
+ taskPrompts: largestFirst(taskPrompts),
245
+ results: largestFirst(results),
246
+ },
247
+ grouping: {
248
+ byPhaseId: sortedCounts(byPhaseId),
249
+ byIssueId: issueCounts,
250
+ byRootCause: sortedCounts(byRootCause),
251
+ loopsPerIssueId: { ...issueCounts },
252
+ },
253
+ verification: {
254
+ entryCount: totals.verificationEntryCount,
255
+ commandCounts: sortedCounts(commandCounts),
256
+ statusCounts: sortedCounts(verificationStatusCounts),
257
+ },
258
+ diagnostics,
259
+ };
260
+ }
261
+
262
+ function largestFirst(values: readonly TaskMetricCount[]): readonly TaskMetricCount[] {
263
+ return [...values]
264
+ .sort((left, right) => right.charCount - left.charCount || compareStrings(left.taskId, right.taskId))
265
+ .slice(0, LARGEST_LIST_LIMIT);
266
+ }
267
+
268
+ function sortedCounts(counts: Readonly<Record<string, number>>): Record<string, number> {
269
+ return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => compareStrings(left, right)));
270
+ }
271
+
272
+ function compareStrings(left: string, right: string): number {
273
+ return left < right ? -1 : left > right ? 1 : 0;
274
+ }
275
+
276
+ function increment(counts: Record<string, number>, key: string): void {
277
+ counts[key] = (counts[key] ?? 0) + 1;
278
+ }
279
+
280
+ function incrementStringField(counts: Record<string, number>, value: unknown): void {
281
+ if (typeof value === "string" && value.length > 0) {
282
+ increment(counts, value);
283
+ }
284
+ }
285
+
286
+ function isRecord(value: unknown): value is Record<string, unknown> {
287
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
288
+ }
@@ -0,0 +1,68 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ export interface SqliteRunResult {
4
+ readonly changes: number;
5
+ }
6
+
7
+ export interface SqliteStatement {
8
+ run(...parameters: readonly unknown[]): SqliteRunResult;
9
+ get(...parameters: readonly unknown[]): unknown;
10
+ all(...parameters: readonly unknown[]): readonly unknown[];
11
+ }
12
+
13
+ export interface SqliteDatabase {
14
+ exec(sql: string): void;
15
+ query(sql: string): SqliteStatement;
16
+ transaction<TValue>(operation: () => TValue): () => TValue;
17
+ close(): void;
18
+ }
19
+
20
+ interface BunSqliteModule {
21
+ readonly Database: new (path: string, options: { readonly create: boolean; readonly strict: boolean }) => SqliteDatabase;
22
+ }
23
+
24
+ interface NodeDatabase {
25
+ exec(sql: string): void;
26
+ prepare(sql: string): SqliteStatement;
27
+ close(): void;
28
+ }
29
+
30
+ interface NodeSqliteModule {
31
+ readonly DatabaseSync: new (path: string) => NodeDatabase;
32
+ }
33
+
34
+ const runtimeRequire = createRequire(import.meta.url);
35
+
36
+ export function openSqliteDatabase(path: string): SqliteDatabase {
37
+ if (process.versions.bun) {
38
+ const sqliteModule = runtimeRequire("bun:sqlite") as BunSqliteModule;
39
+ return new sqliteModule.Database(path, { create: true, strict: true });
40
+ }
41
+
42
+ const sqliteModule = runtimeRequire("node:sqlite") as NodeSqliteModule;
43
+ const database = new sqliteModule.DatabaseSync(path);
44
+ return {
45
+ exec(sql) {
46
+ database.exec(sql);
47
+ },
48
+ query(sql) {
49
+ return database.prepare(sql);
50
+ },
51
+ transaction(operation) {
52
+ return () => {
53
+ database.exec("BEGIN");
54
+ try {
55
+ const value = operation();
56
+ database.exec("COMMIT");
57
+ return value;
58
+ } catch (error) {
59
+ database.exec("ROLLBACK");
60
+ throw error;
61
+ }
62
+ };
63
+ },
64
+ close() {
65
+ database.close();
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,153 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ export interface TaskStoreDiagnostic {
5
+ readonly severity: "warning" | "error";
6
+ readonly code: string;
7
+ readonly path: string;
8
+ readonly message: string;
9
+ }
10
+
11
+ export interface TaskStoreArtifactRecord {
12
+ readonly taskDirectoryName: string;
13
+ readonly task: Record<string, unknown>;
14
+ readonly assignment: unknown | undefined;
15
+ readonly result: Record<string, unknown> | undefined;
16
+ }
17
+
18
+ export interface TaskStoreArtifactRead {
19
+ readonly records: readonly TaskStoreArtifactRecord[];
20
+ readonly diagnostics: readonly TaskStoreDiagnostic[];
21
+ }
22
+
23
+ type JsonReadResult =
24
+ | { readonly ok: true; readonly value: unknown }
25
+ | { readonly ok: false; readonly missing: boolean; readonly message: string };
26
+
27
+ export async function readTaskStoreArtifacts(tasksRoot: string): Promise<TaskStoreArtifactRead> {
28
+ const diagnostics: TaskStoreDiagnostic[] = [];
29
+ const records: TaskStoreArtifactRecord[] = [];
30
+ let entries;
31
+ try {
32
+ entries = await readdir(tasksRoot, { withFileTypes: true });
33
+ } catch (error) {
34
+ diagnostics.push({
35
+ severity: "error",
36
+ code: "tasks_root_unreadable",
37
+ path: tasksRoot,
38
+ message: errorMessage(error),
39
+ });
40
+ return { records, diagnostics };
41
+ }
42
+
43
+ const directories = entries
44
+ .filter((entry) => entry.isDirectory())
45
+ .sort((left, right) => compareStrings(left.name, right.name));
46
+ for (const directory of directories) {
47
+ const taskDirectoryName = directory.name;
48
+ const taskRead = await readJson(join(tasksRoot, taskDirectoryName, "task.json"));
49
+ if (!taskRead.ok) {
50
+ diagnostics.push({
51
+ severity: taskRead.missing ? "warning" : "error",
52
+ code: taskRead.missing ? "missing_task_json" : "malformed_task_json",
53
+ path: join(taskDirectoryName, "task.json"),
54
+ message: taskRead.message,
55
+ });
56
+ continue;
57
+ }
58
+ if (!isRecord(taskRead.value)) {
59
+ diagnostics.push({
60
+ severity: "error",
61
+ code: "malformed_task_json",
62
+ path: join(taskDirectoryName, "task.json"),
63
+ message: "task.json must contain an object",
64
+ });
65
+ continue;
66
+ }
67
+
68
+ const assignment = await readAssignment(tasksRoot, taskDirectoryName, diagnostics);
69
+ const result = await readResult(tasksRoot, taskDirectoryName, diagnostics);
70
+ records.push({ taskDirectoryName, task: taskRead.value, assignment, result });
71
+ }
72
+
73
+ return { records, diagnostics };
74
+ }
75
+
76
+ async function readAssignment(
77
+ tasksRoot: string,
78
+ taskDirectoryName: string,
79
+ diagnostics: TaskStoreDiagnostic[],
80
+ ): Promise<unknown | undefined> {
81
+ const relativePath = join(taskDirectoryName, "assignment.json");
82
+ const assignmentRead = await readJson(join(tasksRoot, relativePath));
83
+ if (assignmentRead.ok) {
84
+ return assignmentRead.value;
85
+ }
86
+ if (!assignmentRead.missing) {
87
+ diagnostics.push({
88
+ severity: "error",
89
+ code: "malformed_assignment_json",
90
+ path: relativePath,
91
+ message: assignmentRead.message,
92
+ });
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ async function readResult(
98
+ tasksRoot: string,
99
+ taskDirectoryName: string,
100
+ diagnostics: TaskStoreDiagnostic[],
101
+ ): Promise<Record<string, unknown> | undefined> {
102
+ const relativePath = join(taskDirectoryName, "result.json");
103
+ const resultRead = await readJson(join(tasksRoot, relativePath));
104
+ if (!resultRead.ok) {
105
+ diagnostics.push({
106
+ severity: resultRead.missing ? "warning" : "error",
107
+ code: resultRead.missing ? "missing_result_json" : "malformed_result_json",
108
+ path: relativePath,
109
+ message: resultRead.message,
110
+ });
111
+ return undefined;
112
+ }
113
+ if (!isRecord(resultRead.value)) {
114
+ diagnostics.push({
115
+ severity: "error",
116
+ code: "malformed_result_json",
117
+ path: relativePath,
118
+ message: "result.json must contain an object",
119
+ });
120
+ return undefined;
121
+ }
122
+ return resultRead.value;
123
+ }
124
+
125
+ async function readJson(path: string): Promise<JsonReadResult> {
126
+ let text: string;
127
+ try {
128
+ text = await readFile(path, "utf8");
129
+ } catch (error) {
130
+ return { ok: false, missing: errorCode(error) === "ENOENT", message: errorMessage(error) };
131
+ }
132
+ try {
133
+ return { ok: true, value: JSON.parse(text) as unknown };
134
+ } catch (error) {
135
+ return { ok: false, missing: false, message: errorMessage(error) };
136
+ }
137
+ }
138
+
139
+ function compareStrings(left: string, right: string): number {
140
+ return left < right ? -1 : left > right ? 1 : 0;
141
+ }
142
+
143
+ function isRecord(value: unknown): value is Record<string, unknown> {
144
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
145
+ }
146
+
147
+ function errorCode(error: unknown): string | undefined {
148
+ return isRecord(error) && typeof error.code === "string" ? error.code : undefined;
149
+ }
150
+
151
+ function errorMessage(error: unknown): string {
152
+ return error instanceof Error ? error.message : String(error);
153
+ }