@femtomc/mu-issue 0.1.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 ADDED
@@ -0,0 +1,49 @@
1
+ # @femtomc/mu-issue
2
+
3
+ Issue store backed by a JSONL store, with dependency edges and DAG helpers (ready leaves, validate, deps).
4
+
5
+ ## Install
6
+
7
+ After publishing:
8
+
9
+ ```bash
10
+ npm install @femtomc/mu-issue
11
+ # or: bun add @femtomc/mu-issue
12
+ ```
13
+
14
+ From this repo:
15
+
16
+ ```bash
17
+ cd mu
18
+ bun install
19
+ bun run build
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { InMemoryJsonlStore } from "@femtomc/mu-core";
26
+ import { IssueStore } from "@femtomc/mu-issue";
27
+
28
+ const issues = new IssueStore(new InMemoryJsonlStore());
29
+
30
+ const root = await issues.create("root");
31
+ const leaf = await issues.create("do work", { tags: ["node:agent"] });
32
+ await issues.add_dep(leaf.id, "parent", root.id);
33
+
34
+ console.log(await issues.ready(root.id));
35
+ ```
36
+
37
+ ## Tests / Typecheck
38
+
39
+ From the `mu/` repo root:
40
+
41
+ ```bash
42
+ bun test packages/issue
43
+ bun run typecheck
44
+ ```
45
+
46
+ ## Runtime
47
+
48
+ - Runtime-agnostic: works in Node or the browser.
49
+ - You provide a `JsonlStore` implementation (see `@femtomc/mu-core/node` for `FsJsonlStore`, or `@femtomc/mu-core/browser` for IndexedDB/localStorage stores).
@@ -0,0 +1,3 @@
1
+ export type { CreateIssueOpts, ListIssueOpts } from "./issue_store.js";
2
+ export { IssueStore } from "./issue_store.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACvE,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { IssueStore } from "./issue_store.js";
@@ -0,0 +1,37 @@
1
+ import type { Issue, JsonlStore, ValidationResult } from "@femtomc/mu-core";
2
+ import { EventLog } from "@femtomc/mu-core";
3
+ export type CreateIssueOpts = {
4
+ body?: string;
5
+ tags?: string[];
6
+ executionSpec?: Record<string, unknown> | null;
7
+ execution_spec?: Record<string, unknown> | null;
8
+ priority?: number;
9
+ };
10
+ export type ListIssueOpts = {
11
+ status?: Issue["status"];
12
+ tag?: string;
13
+ };
14
+ export declare class IssueStore {
15
+ #private;
16
+ readonly events: EventLog;
17
+ constructor(issues: JsonlStore<unknown>, opts?: {
18
+ events?: EventLog;
19
+ });
20
+ create(title: string, opts?: CreateIssueOpts): Promise<Issue>;
21
+ get(issueId: string): Promise<Issue | null>;
22
+ list(opts?: ListIssueOpts): Promise<Issue[]>;
23
+ update(issueId: string, fields: Record<string, unknown>): Promise<Issue>;
24
+ claim(issueId: string): Promise<boolean>;
25
+ close(issueId: string, outcome?: string): Promise<Issue>;
26
+ reset_in_progress(rootId: string): Promise<string[]>;
27
+ add_dep(srcId: string, depType: string, dstId: string): Promise<void>;
28
+ remove_dep(srcId: string, depType: string, dstId: string): Promise<boolean>;
29
+ children(parentId: string): Promise<Issue[]>;
30
+ subtree_ids(rootId: string): Promise<string[]>;
31
+ ready(rootId?: string | null, opts?: {
32
+ tags?: readonly string[] | null;
33
+ }): Promise<Issue[]>;
34
+ collapsible(rootId: string): Promise<Issue[]>;
35
+ validate(rootId: string): Promise<ValidationResult>;
36
+ }
37
+ //# sourceMappingURL=issue_store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"issue_store.d.ts","sourceRoot":"","sources":["../src/issue_store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EAGN,QAAQ,EAOR,MAAM,kBAAkB,CAAC;AAE1B,MAAM,MAAM,eAAe,GAAG;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC/C,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAChD,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC3B,MAAM,CAAC,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;CACb,CAAC;AAgDF,qBAAa,UAAU;;IACtB,SAAgB,MAAM,EAAE,QAAQ,CAAC;gBAGd,MAAM,EAAE,UAAU,CAAC,OAAO,CAAC,EAAE,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,QAAQ,CAAA;KAAO;IAwBnE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,KAAK,CAAC;IA6BjE,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,GAAG,IAAI,CAAC;IAM3C,IAAI,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAWhD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC;IA0ExE,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA+BxC,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,MAAkB,GAAG,OAAO,CAAC,KAAK,CAAC;IAInE,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAmBpD,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAyBrE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA2B3E,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAK5C,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAS9C,KAAK,CAAC,MAAM,GAAE,MAAM,GAAG,IAAW,EAAE,IAAI,GAAE;QAAE,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAA;KAAO,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAOrG,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;IAK7C,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;CAIhE"}
@@ -0,0 +1,292 @@
1
+ import { collapsible as dagCollapsible, subtreeIds as dagSubtreeIds, EventLog, IssueSchema, NullEventSink, nowTs, readyLeaves, shortId, validateDag, } from "@femtomc/mu-core";
2
+ function deepEqualJson(a, b) {
3
+ if (a === b) {
4
+ return true;
5
+ }
6
+ if (typeof a !== typeof b) {
7
+ return false;
8
+ }
9
+ if (a == null || b == null) {
10
+ return false;
11
+ }
12
+ if (Array.isArray(a) || Array.isArray(b)) {
13
+ if (!Array.isArray(a) || !Array.isArray(b)) {
14
+ return false;
15
+ }
16
+ if (a.length !== b.length) {
17
+ return false;
18
+ }
19
+ for (let i = 0; i < a.length; i++) {
20
+ if (!deepEqualJson(a[i], b[i])) {
21
+ return false;
22
+ }
23
+ }
24
+ return true;
25
+ }
26
+ if (typeof a === "object") {
27
+ const ao = a;
28
+ const bo = b;
29
+ const ak = Object.keys(ao).sort();
30
+ const bk = Object.keys(bo).sort();
31
+ if (ak.length !== bk.length) {
32
+ return false;
33
+ }
34
+ for (let i = 0; i < ak.length; i++) {
35
+ const key = ak[i];
36
+ if (key !== bk[i]) {
37
+ return false;
38
+ }
39
+ if (!deepEqualJson(ao[key], bo[key])) {
40
+ return false;
41
+ }
42
+ }
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+ export class IssueStore {
48
+ events;
49
+ #issues;
50
+ constructor(issues, opts = {}) {
51
+ this.#issues = issues;
52
+ this.events = opts.events ?? new EventLog(new NullEventSink());
53
+ }
54
+ async #load() {
55
+ const rows = await this.#issues.read();
56
+ return rows.map((row, idx) => {
57
+ const parsed = IssueSchema.safeParse(row);
58
+ if (!parsed.success) {
59
+ throw new Error(`invalid issue row ${idx}: ${parsed.error.message}`);
60
+ }
61
+ return parsed.data;
62
+ });
63
+ }
64
+ async #save(rows) {
65
+ await this.#issues.write(rows);
66
+ }
67
+ #findIndex(rows, issueId) {
68
+ return rows.findIndex((row) => row.id === issueId);
69
+ }
70
+ async create(title, opts = {}) {
71
+ const now = nowTs();
72
+ const issueInput = {
73
+ id: `mu-${shortId()}`,
74
+ title,
75
+ body: opts.body ?? "",
76
+ status: "open",
77
+ outcome: null,
78
+ tags: opts.tags ?? [],
79
+ deps: [],
80
+ execution_spec: opts.execution_spec ?? opts.executionSpec ?? null,
81
+ priority: opts.priority ?? 3,
82
+ created_at: now,
83
+ updated_at: now,
84
+ };
85
+ const issue = IssueSchema.parse(issueInput);
86
+ const rows = await this.#load();
87
+ rows.push(issue);
88
+ await this.#save(rows);
89
+ await this.events.emit("issue.create", {
90
+ source: "issue_store",
91
+ issueId: issue.id,
92
+ payload: { issue },
93
+ });
94
+ return issue;
95
+ }
96
+ async get(issueId) {
97
+ const rows = await this.#load();
98
+ const idx = this.#findIndex(rows, issueId);
99
+ return idx >= 0 ? rows[idx] : null;
100
+ }
101
+ async list(opts = {}) {
102
+ let rows = await this.#load();
103
+ if (opts.status) {
104
+ rows = rows.filter((row) => row.status === opts.status);
105
+ }
106
+ if (opts.tag) {
107
+ rows = rows.filter((row) => row.tags.includes(opts.tag));
108
+ }
109
+ return rows;
110
+ }
111
+ async update(issueId, fields) {
112
+ const rows = await this.#load();
113
+ const idx = this.#findIndex(rows, issueId);
114
+ if (idx < 0) {
115
+ throw new Error(issueId);
116
+ }
117
+ const issueBefore = rows[idx];
118
+ const before = JSON.parse(JSON.stringify(issueBefore));
119
+ for (const [key, _value] of Object.entries(fields)) {
120
+ if (key === "id") {
121
+ continue;
122
+ }
123
+ issueBefore[key] = _value;
124
+ }
125
+ issueBefore.updated_at = nowTs();
126
+ const issueAfter = IssueSchema.parse(issueBefore);
127
+ rows[idx] = issueAfter;
128
+ await this.#save(rows);
129
+ const changed = {};
130
+ for (const [key, _value] of Object.entries(fields)) {
131
+ if (key === "id") {
132
+ continue;
133
+ }
134
+ const from = before[key];
135
+ const to = issueAfter[key];
136
+ if (!deepEqualJson(from, to)) {
137
+ changed[key] = { from, to };
138
+ }
139
+ }
140
+ const fieldsWithoutId = {};
141
+ for (const [k, v] of Object.entries(fields)) {
142
+ if (k === "id") {
143
+ continue;
144
+ }
145
+ fieldsWithoutId[k] = v;
146
+ }
147
+ await this.events.emit("issue.update", {
148
+ source: "issue_store",
149
+ issueId,
150
+ payload: { changed, fields: fieldsWithoutId },
151
+ });
152
+ if (before.status !== issueAfter.status) {
153
+ const status = issueAfter.status;
154
+ if (status === "open") {
155
+ await this.events.emit("issue.open", {
156
+ source: "issue_store",
157
+ issueId,
158
+ payload: { from: before.status, to: status },
159
+ });
160
+ }
161
+ else if (status === "closed") {
162
+ await this.events.emit("issue.close", {
163
+ source: "issue_store",
164
+ issueId,
165
+ payload: { from: before.status, to: status, outcome: issueAfter.outcome },
166
+ });
167
+ }
168
+ else if (status === "in_progress") {
169
+ await this.events.emit("issue.claim", {
170
+ source: "issue_store",
171
+ issueId,
172
+ payload: { from: before.status, to: status, ok: true },
173
+ });
174
+ }
175
+ }
176
+ return issueAfter;
177
+ }
178
+ async claim(issueId) {
179
+ const rows = await this.#load();
180
+ const idx = this.#findIndex(rows, issueId);
181
+ if (idx < 0) {
182
+ await this.events.emit("issue.claim", {
183
+ source: "issue_store",
184
+ issueId,
185
+ payload: { ok: false, reason: "not_found" },
186
+ });
187
+ return false;
188
+ }
189
+ const issue = rows[idx];
190
+ if (issue.status !== "open") {
191
+ await this.events.emit("issue.claim", {
192
+ source: "issue_store",
193
+ issueId,
194
+ payload: { ok: false, reason: `status=${issue.status}` },
195
+ });
196
+ return false;
197
+ }
198
+ issue.status = "in_progress";
199
+ issue.updated_at = nowTs();
200
+ rows[idx] = IssueSchema.parse(issue);
201
+ await this.#save(rows);
202
+ await this.events.emit("issue.claim", { source: "issue_store", issueId, payload: { ok: true } });
203
+ return true;
204
+ }
205
+ async close(issueId, outcome = "success") {
206
+ return await this.update(issueId, { status: "closed", outcome });
207
+ }
208
+ async reset_in_progress(rootId) {
209
+ const rows = await this.#load();
210
+ const idsInScope = new Set(this.#subtreeIds(rows, rootId));
211
+ const reset = [];
212
+ for (const row of rows) {
213
+ if (idsInScope.has(row.id) && row.status === "in_progress") {
214
+ row.status = "open";
215
+ row.updated_at = nowTs();
216
+ reset.push(row.id);
217
+ }
218
+ }
219
+ if (reset.length > 0) {
220
+ await this.#save(rows.map((row) => IssueSchema.parse(row)));
221
+ }
222
+ return reset;
223
+ }
224
+ async add_dep(srcId, depType, dstId) {
225
+ const rows = await this.#load();
226
+ const idx = this.#findIndex(rows, srcId);
227
+ if (idx < 0) {
228
+ throw new Error(srcId);
229
+ }
230
+ const issue = rows[idx];
231
+ const exists = issue.deps.some((dep) => dep.type === depType && dep.target === dstId);
232
+ if (exists) {
233
+ return;
234
+ }
235
+ issue.deps.push({ type: depType, target: dstId });
236
+ issue.updated_at = nowTs();
237
+ rows[idx] = IssueSchema.parse(issue);
238
+ await this.#save(rows);
239
+ await this.events.emit("issue.dep.add", {
240
+ source: "issue_store",
241
+ issueId: srcId,
242
+ payload: { type: depType, target: dstId },
243
+ });
244
+ }
245
+ async remove_dep(srcId, depType, dstId) {
246
+ const rows = await this.#load();
247
+ const idx = this.#findIndex(rows, srcId);
248
+ if (idx < 0) {
249
+ throw new Error(srcId);
250
+ }
251
+ const issue = rows[idx];
252
+ const before = issue.deps.length;
253
+ issue.deps = issue.deps.filter((dep) => !(dep.type === depType && dep.target === dstId));
254
+ const changed = issue.deps.length !== before;
255
+ if (changed) {
256
+ issue.updated_at = nowTs();
257
+ rows[idx] = IssueSchema.parse(issue);
258
+ await this.#save(rows);
259
+ }
260
+ await this.events.emit("issue.dep.remove", {
261
+ source: "issue_store",
262
+ issueId: srcId,
263
+ payload: { type: depType, target: dstId, ok: changed },
264
+ });
265
+ return changed;
266
+ }
267
+ async children(parentId) {
268
+ const rows = await this.#load();
269
+ return rows.filter((row) => row.deps.some((dep) => dep.type === "parent" && dep.target === parentId));
270
+ }
271
+ async subtree_ids(rootId) {
272
+ const rows = await this.#load();
273
+ return this.#subtreeIds(rows, rootId);
274
+ }
275
+ #subtreeIds(issues, rootId) {
276
+ return dagSubtreeIds(issues, rootId);
277
+ }
278
+ async ready(rootId = null, opts = {}) {
279
+ const rows = await this.#load();
280
+ const tags = opts.tags ?? undefined;
281
+ const root_id = rootId ?? undefined;
282
+ return readyLeaves(rows, { root_id, tags: tags ?? undefined });
283
+ }
284
+ async collapsible(rootId) {
285
+ const rows = await this.#load();
286
+ return dagCollapsible(rows, rootId);
287
+ }
288
+ async validate(rootId) {
289
+ const rows = await this.#load();
290
+ return validateDag(rows, rootId);
291
+ }
292
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@femtomc/mu-issue",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": ["dist/**"],
14
+ "dependencies": {
15
+ "@femtomc/mu-core": "0.1.0"
16
+ }
17
+ }