@frehilm/ordna-core 0.1.4 → 0.2.1
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 +34 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -1
- package/dist/config.js.map +1 -1
- package/dist/git.d.ts +10 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +14 -27
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +18 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js.map +1 -1
- package/dist/storage/auto-detect.d.ts +82 -0
- package/dist/storage/auto-detect.d.ts.map +1 -0
- package/dist/storage/auto-detect.js +158 -0
- package/dist/storage/auto-detect.js.map +1 -0
- package/dist/storage/auto-push.d.ts +36 -0
- package/dist/storage/auto-push.d.ts.map +1 -0
- package/dist/storage/auto-push.js +100 -0
- package/dist/storage/auto-push.js.map +1 -0
- package/dist/storage/backend.d.ts +86 -0
- package/dist/storage/backend.d.ts.map +1 -0
- package/dist/storage/backend.js +14 -0
- package/dist/storage/backend.js.map +1 -0
- package/dist/storage/backends/file.d.ts +31 -0
- package/dist/storage/backends/file.d.ts.map +1 -0
- package/dist/storage/backends/file.js +213 -0
- package/dist/storage/backends/file.js.map +1 -0
- package/dist/storage/backends/hybrid.d.ts +39 -0
- package/dist/storage/backends/hybrid.d.ts.map +1 -0
- package/dist/storage/backends/hybrid.js +270 -0
- package/dist/storage/backends/hybrid.js.map +1 -0
- package/dist/storage/backends/namespace.d.ts +55 -0
- package/dist/storage/backends/namespace.d.ts.map +1 -0
- package/dist/storage/backends/namespace.js +907 -0
- package/dist/storage/backends/namespace.js.map +1 -0
- package/dist/storage/file-io.d.ts +38 -0
- package/dist/storage/file-io.d.ts.map +1 -0
- package/dist/storage/file-io.js +69 -0
- package/dist/storage/file-io.js.map +1 -0
- package/dist/storage/git-ref.d.ts +77 -0
- package/dist/storage/git-ref.d.ts.map +1 -0
- package/dist/storage/git-ref.js +184 -0
- package/dist/storage/git-ref.js.map +1 -0
- package/dist/storage/markdown.d.ts +17 -0
- package/dist/storage/markdown.d.ts.map +1 -0
- package/dist/storage/markdown.js +17 -0
- package/dist/storage/markdown.js.map +1 -0
- package/dist/storage/sync-ref.d.ts +82 -0
- package/dist/storage/sync-ref.d.ts.map +1 -0
- package/dist/storage/sync-ref.js +191 -0
- package/dist/storage/sync-ref.js.map +1 -0
- package/dist/store.d.ts +56 -8
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +93 -115
- package/dist/store.js.map +1 -1
- package/dist/watcher.d.ts +33 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +12 -30
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import { parseId } from "../../ids.js";
|
|
2
|
+
import { ARCHIVED_STATUS, isKnownStatus, } from "../backend.js";
|
|
3
|
+
import { GitRunner } from "../git-ref.js";
|
|
4
|
+
import { defaultSectionsFor, parseTaskBytes, serializeTask, } from "../markdown.js";
|
|
5
|
+
import { SyncRef } from "../sync-ref.js";
|
|
6
|
+
const REF_PREFIX = "refs/ordna/tasks/";
|
|
7
|
+
const TASK_REF_PATTERN = `${REF_PREFIX}*`;
|
|
8
|
+
const NAMESPACE_FETCH_REFSPEC = `+${REF_PREFIX}*:${REF_PREFIX}*`;
|
|
9
|
+
const STATE_REF_NAME = "refs/ordna/state";
|
|
10
|
+
const STATE_PUSH_REFSPEC = `+${STATE_REF_NAME}:${STATE_REF_NAME}`;
|
|
11
|
+
const STATE_FETCH_REFSPEC = `+${STATE_REF_NAME}:${STATE_REF_NAME}`;
|
|
12
|
+
const PUSH_DEBOUNCE_MS = 50;
|
|
13
|
+
// Sentinel refname used internally to schedule the state ref push.
|
|
14
|
+
const STATE_PUSH_SENTINEL = STATE_REF_NAME;
|
|
15
|
+
function today() {
|
|
16
|
+
return new Date().toISOString().slice(0, 10);
|
|
17
|
+
}
|
|
18
|
+
function nowIso() {
|
|
19
|
+
return new Date().toISOString();
|
|
20
|
+
}
|
|
21
|
+
function refnameFor(id) {
|
|
22
|
+
return `${REF_PREFIX}${id}`;
|
|
23
|
+
}
|
|
24
|
+
function idFromRefname(refname) {
|
|
25
|
+
if (!refname.startsWith(REF_PREFIX))
|
|
26
|
+
return null;
|
|
27
|
+
const id = refname.slice(REF_PREFIX.length);
|
|
28
|
+
return id.length > 0 ? id : null;
|
|
29
|
+
}
|
|
30
|
+
function isCASConflict(err) {
|
|
31
|
+
if (!(err instanceof Error))
|
|
32
|
+
return false;
|
|
33
|
+
const msg = err.message.toLowerCase();
|
|
34
|
+
return (msg.includes("update-ref") &&
|
|
35
|
+
(msg.includes("cannot lock ref") ||
|
|
36
|
+
msg.includes("is at") ||
|
|
37
|
+
msg.includes("expected") ||
|
|
38
|
+
msg.includes("missing")));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Heuristic: detect a push rejection from the thrown git error. Covers
|
|
42
|
+
* the three flavours we care about:
|
|
43
|
+
* - `[rejected] ... (stale info)` — `--force-with-lease` denied
|
|
44
|
+
* - `[rejected] ... (non-fast-forward)` — plain refused update
|
|
45
|
+
* - `[rejected] ... (fetch first)` — same family
|
|
46
|
+
*
|
|
47
|
+
* Network failures and auth errors fall through to the generic logger.
|
|
48
|
+
*/
|
|
49
|
+
function isPushRejection(err) {
|
|
50
|
+
if (!(err instanceof Error))
|
|
51
|
+
return false;
|
|
52
|
+
const msg = err.message.toLowerCase();
|
|
53
|
+
return (msg.includes("rejected") ||
|
|
54
|
+
msg.includes("stale info") ||
|
|
55
|
+
msg.includes("non-fast-forward") ||
|
|
56
|
+
msg.includes("fetch first"));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Namespace storage backend.
|
|
60
|
+
*
|
|
61
|
+
* Tasks live as git **blobs** under `refs/ordna/tasks/<id>` — one ref
|
|
62
|
+
* per task, no working-tree files. `git status` stays clean. `git log`
|
|
63
|
+
* on branches doesn't see task mutations.
|
|
64
|
+
*
|
|
65
|
+
* **ID allocation.** A shared `refs/ordna/state` ref carries a
|
|
66
|
+
* `SyncRef`-managed JSON blob `{next_id, ops}`. Same primitive as
|
|
67
|
+
* hybrid: CAS in-process, auto-fetch-and-retry on conflict.
|
|
68
|
+
*
|
|
69
|
+
* **Bootstrap.** On `init()` if the state ref is missing, we scan
|
|
70
|
+
* existing `refs/ordna/tasks/*` and seed `next_id` from the max
|
|
71
|
+
* numeric id. Safe across concurrent processes (CAS).
|
|
72
|
+
*
|
|
73
|
+
* **Sync.** Every mutation schedules a per-ref push with
|
|
74
|
+
* `--force-with-lease` (per-ref CAS at the protocol level). On a
|
|
75
|
+
* rejected `create` (offline collision), the backend fetches,
|
|
76
|
+
* reallocates a fresh id via `SyncRef`, rewrites the local blob's
|
|
77
|
+
* `id:` field, cascades the rewrite through any local `depends_on`
|
|
78
|
+
* references to the old id, and emits a `renamed` event. Update
|
|
79
|
+
* collisions are deliberately loud — silently picking a winner would
|
|
80
|
+
* lose user edits.
|
|
81
|
+
*
|
|
82
|
+
* **Auto-fetch.** A configurable timer (default 60s) keeps the local
|
|
83
|
+
* snapshot fresh without manual pulls. A `fetch()` method exposes it
|
|
84
|
+
* manually for the TUI key / web button.
|
|
85
|
+
*
|
|
86
|
+
* **Audit log.** The `ops` array in the state blob records every
|
|
87
|
+
* `create`/`update`/`archive`/`delete`/`rename`. `rename` entries
|
|
88
|
+
* carry `renamedFrom` so the UIs can show a "previously known as X"
|
|
89
|
+
* banner on the affected task.
|
|
90
|
+
*/
|
|
91
|
+
export class NamespaceBackend {
|
|
92
|
+
cwd;
|
|
93
|
+
config;
|
|
94
|
+
kind = "namespace";
|
|
95
|
+
#initPromise = null;
|
|
96
|
+
#git;
|
|
97
|
+
#sync = null;
|
|
98
|
+
#cachedActor = null;
|
|
99
|
+
// Push pipeline (replaces the simple PushQueue used in earlier T-032).
|
|
100
|
+
#pendingPushes = new Map();
|
|
101
|
+
#pushTimer = null;
|
|
102
|
+
#pushInFlight = null;
|
|
103
|
+
#pushRetryPending = false;
|
|
104
|
+
// Watcher (poll-based, refs have no kernel-level change notification).
|
|
105
|
+
#pollTimer = null;
|
|
106
|
+
#listeners = new Set();
|
|
107
|
+
#lastSnapshot = new Map();
|
|
108
|
+
#pollIntervalMs;
|
|
109
|
+
// Auto-fetch (60s by default; 0 disables).
|
|
110
|
+
#autoFetchIntervalMs;
|
|
111
|
+
#autoFetchTimer = null;
|
|
112
|
+
#autoFetchInFlight = null;
|
|
113
|
+
#remoteChecked = false;
|
|
114
|
+
#remoteExists = false;
|
|
115
|
+
#disposed = false;
|
|
116
|
+
#autoRenumberOnConflict;
|
|
117
|
+
constructor(cwd, config) {
|
|
118
|
+
this.cwd = cwd;
|
|
119
|
+
this.config = config;
|
|
120
|
+
this.#git = new GitRunner(cwd);
|
|
121
|
+
this.#pollIntervalMs = config.namespace?.pollIntervalMs ?? 1000;
|
|
122
|
+
this.#autoFetchIntervalMs = config.namespace?.autoFetchIntervalMs ?? 60000;
|
|
123
|
+
this.#autoRenumberOnConflict =
|
|
124
|
+
config.namespace?.autoRenumberOnConflict ?? true;
|
|
125
|
+
}
|
|
126
|
+
async init() {
|
|
127
|
+
await this.#git.ensureRepository();
|
|
128
|
+
this.#sync = new SyncRef(this.#git, STATE_REF_NAME);
|
|
129
|
+
await this.#bootstrapStateIfMissing();
|
|
130
|
+
if (this.#autoFetchIntervalMs > 0) {
|
|
131
|
+
this.#scheduleAutoFetch();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async #bootstrapStateIfMissing() {
|
|
135
|
+
const sync = this.#sync;
|
|
136
|
+
// Compute the high-water mark from existing task refs so an upgrade
|
|
137
|
+
// from a pre-state-ref namespace install (or a fresh clone before
|
|
138
|
+
// the state ref was pushed) gets the correct next_id.
|
|
139
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
140
|
+
let maxNumeric = 0;
|
|
141
|
+
for (const { refname } of refs) {
|
|
142
|
+
const id = idFromRefname(refname);
|
|
143
|
+
if (!id)
|
|
144
|
+
continue;
|
|
145
|
+
const n = parseId(this.config, id);
|
|
146
|
+
if (n !== null && n > maxNumeric)
|
|
147
|
+
maxNumeric = n;
|
|
148
|
+
}
|
|
149
|
+
// ensureInitialized only writes if the state ref doesn't yet exist
|
|
150
|
+
// (CAS with empty expected). If two processes race, one wins and
|
|
151
|
+
// the other adopts.
|
|
152
|
+
await sync.ensureInitialized({
|
|
153
|
+
next_id: maxNumeric + 1,
|
|
154
|
+
ops: [],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async #ensureInit() {
|
|
158
|
+
if (!this.#initPromise)
|
|
159
|
+
this.#initPromise = this.init();
|
|
160
|
+
return this.#initPromise;
|
|
161
|
+
}
|
|
162
|
+
async dispose() {
|
|
163
|
+
this.#disposed = true;
|
|
164
|
+
if (this.#pollTimer) {
|
|
165
|
+
clearTimeout(this.#pollTimer);
|
|
166
|
+
this.#pollTimer = null;
|
|
167
|
+
}
|
|
168
|
+
if (this.#autoFetchTimer) {
|
|
169
|
+
clearTimeout(this.#autoFetchTimer);
|
|
170
|
+
this.#autoFetchTimer = null;
|
|
171
|
+
}
|
|
172
|
+
if (this.#autoFetchInFlight) {
|
|
173
|
+
try {
|
|
174
|
+
await this.#autoFetchInFlight;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// already logged in #runAutoFetch
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Flush any pending pushes so the last mutation lands on origin
|
|
181
|
+
// before the process exits. flushPushes cancels the debounce
|
|
182
|
+
// timer itself; we leave #pushTimer alone here so it can hand
|
|
183
|
+
// the pending batch to the flusher.
|
|
184
|
+
await this.#flushPushes();
|
|
185
|
+
this.#listeners.clear();
|
|
186
|
+
this.#lastSnapshot.clear();
|
|
187
|
+
}
|
|
188
|
+
// ---------------- reads ----------------
|
|
189
|
+
async list(options = {}) {
|
|
190
|
+
await this.#ensureInit();
|
|
191
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
192
|
+
const renamedMap = await this.#buildRenamedFromMap();
|
|
193
|
+
const tasks = [];
|
|
194
|
+
for (const { refname, oid } of refs) {
|
|
195
|
+
const id = idFromRefname(refname);
|
|
196
|
+
if (!id)
|
|
197
|
+
continue;
|
|
198
|
+
try {
|
|
199
|
+
const raw = await this.#git.catBlob(oid);
|
|
200
|
+
const task = parseTaskBytes(raw, `ref:${refname}`);
|
|
201
|
+
// filePath is set by parseTaskBytes to the synthetic
|
|
202
|
+
// `ref:` value — strip so consumers see undefined and
|
|
203
|
+
// take the "no on-disk file" branch.
|
|
204
|
+
delete task.filePath;
|
|
205
|
+
const renamedFrom = renamedMap.get(id);
|
|
206
|
+
if (renamedFrom)
|
|
207
|
+
task.renamed_from = renamedFrom;
|
|
208
|
+
tasks.push(task);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Skip unreadable / malformed blobs silently.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let filtered = tasks;
|
|
215
|
+
if (options.status)
|
|
216
|
+
filtered = filtered.filter((t) => t.status === options.status);
|
|
217
|
+
if (options.assignee)
|
|
218
|
+
filtered = filtered.filter((t) => t.assignee === options.assignee);
|
|
219
|
+
if (options.tag) {
|
|
220
|
+
const tag = options.tag;
|
|
221
|
+
filtered = filtered.filter((t) => t.tags.includes(tag));
|
|
222
|
+
}
|
|
223
|
+
filtered.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
224
|
+
return filtered;
|
|
225
|
+
}
|
|
226
|
+
async get(id) {
|
|
227
|
+
await this.#ensureInit();
|
|
228
|
+
const refname = refnameFor(id);
|
|
229
|
+
const refs = await this.#git.forEachRef(refname);
|
|
230
|
+
const entry = refs.find((r) => r.refname === refname);
|
|
231
|
+
if (!entry)
|
|
232
|
+
return null;
|
|
233
|
+
try {
|
|
234
|
+
const raw = await this.#git.catBlob(entry.oid);
|
|
235
|
+
const task = parseTaskBytes(raw, `ref:${refname}`);
|
|
236
|
+
delete task.filePath;
|
|
237
|
+
const renamedFrom = await this.#lookupRenamedFrom(id);
|
|
238
|
+
if (renamedFrom)
|
|
239
|
+
task.renamed_from = renamedFrom;
|
|
240
|
+
return task;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// ---------------- writes ----------------
|
|
247
|
+
async create(input) {
|
|
248
|
+
await this.#ensureInit();
|
|
249
|
+
const sync = this.#sync;
|
|
250
|
+
const status = input.status ?? this.config.statuses[0];
|
|
251
|
+
if (!status)
|
|
252
|
+
throw new Error("Config has no statuses defined.");
|
|
253
|
+
if (!isKnownStatus(this.config, status)) {
|
|
254
|
+
throw new Error(`Status "${status}" is not in configured statuses.`);
|
|
255
|
+
}
|
|
256
|
+
// Allocate via SyncRef — CAS-retries on conflict, auto-fetches the
|
|
257
|
+
// state ref from origin before retry.
|
|
258
|
+
const id = await sync.allocateNextId(this.config);
|
|
259
|
+
const now = today();
|
|
260
|
+
const task = {
|
|
261
|
+
id,
|
|
262
|
+
title: input.title,
|
|
263
|
+
status,
|
|
264
|
+
assignee: input.assignee ?? null,
|
|
265
|
+
priority: input.priority ?? null,
|
|
266
|
+
tags: input.tags ?? [],
|
|
267
|
+
depends_on: input.depends_on ?? [],
|
|
268
|
+
created_at: now,
|
|
269
|
+
updated_at: now,
|
|
270
|
+
sections: defaultSectionsFor(this.config.schema),
|
|
271
|
+
extra_frontmatter: {},
|
|
272
|
+
rawContent: "",
|
|
273
|
+
};
|
|
274
|
+
const serialized = serializeTask(task, this.config.schema);
|
|
275
|
+
task.rawContent = serialized;
|
|
276
|
+
// hash-object first — blob is durable in .git/objects/ from here;
|
|
277
|
+
// any subsequent failure leaves an orphan, recoverable until the
|
|
278
|
+
// next `git gc --prune`.
|
|
279
|
+
const newOid = await this.#git.hashObject(serialized);
|
|
280
|
+
// CAS update-ref with empty expected-old. Belt-and-braces: SyncRef
|
|
281
|
+
// just handed us a fresh id, so a local collision means the state
|
|
282
|
+
// ref is out of sync with reality. We surface that loudly rather
|
|
283
|
+
// than silently mask it.
|
|
284
|
+
try {
|
|
285
|
+
await this.#git.updateRef(refnameFor(id), newOid, "");
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
if (isCASConflict(err)) {
|
|
289
|
+
throw new Error(`ordna: ${id} already exists locally despite a fresh allocation. State ref may be out of sync — try \`git update-ref -d ${STATE_REF_NAME}\` and retry; the next init() will reseed from existing task refs.`);
|
|
290
|
+
}
|
|
291
|
+
throw err;
|
|
292
|
+
}
|
|
293
|
+
await sync.appendOp(await this.#buildOp("create", id));
|
|
294
|
+
this.#schedulePush({
|
|
295
|
+
refname: refnameFor(id),
|
|
296
|
+
newOid,
|
|
297
|
+
expectedOld: "",
|
|
298
|
+
isCreate: true,
|
|
299
|
+
});
|
|
300
|
+
this.#schedulePushState();
|
|
301
|
+
return task;
|
|
302
|
+
}
|
|
303
|
+
async update(id, patch) {
|
|
304
|
+
await this.#ensureInit();
|
|
305
|
+
const sync = this.#sync;
|
|
306
|
+
const refname = refnameFor(id);
|
|
307
|
+
const refs = await this.#git.forEachRef(refname);
|
|
308
|
+
const entry = refs.find((r) => r.refname === refname);
|
|
309
|
+
if (!entry)
|
|
310
|
+
throw new Error(`Task ${id} not found.`);
|
|
311
|
+
const currentOid = entry.oid;
|
|
312
|
+
const raw = await this.#git.catBlob(currentOid);
|
|
313
|
+
const existing = parseTaskBytes(raw, `ref:${refname}`);
|
|
314
|
+
delete existing.filePath;
|
|
315
|
+
const next = {
|
|
316
|
+
...existing,
|
|
317
|
+
title: patch.title ?? existing.title,
|
|
318
|
+
status: patch.status ?? existing.status,
|
|
319
|
+
assignee: patch.assignee !== undefined ? patch.assignee : existing.assignee,
|
|
320
|
+
priority: patch.priority !== undefined ? patch.priority : existing.priority,
|
|
321
|
+
tags: patch.tags ?? existing.tags,
|
|
322
|
+
depends_on: patch.depends_on ?? existing.depends_on,
|
|
323
|
+
sections: patch.sections ?? existing.sections,
|
|
324
|
+
updated_at: today(),
|
|
325
|
+
};
|
|
326
|
+
if (next.status !== existing.status &&
|
|
327
|
+
!isKnownStatus(this.config, next.status)) {
|
|
328
|
+
throw new Error(`Status "${next.status}" is not in configured statuses.`);
|
|
329
|
+
}
|
|
330
|
+
const serialized = serializeTask(next, this.config.schema);
|
|
331
|
+
next.rawContent = serialized;
|
|
332
|
+
const newOid = await this.#git.hashObject(serialized);
|
|
333
|
+
try {
|
|
334
|
+
await this.#git.updateRef(refname, newOid, currentOid);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (isCASConflict(err)) {
|
|
338
|
+
throw new Error(`ordna: ${id} moved underneath us. Another writer updated this task between our read and write; pull (\`git fetch origin '+${refname}:${refname}'\`) and retry.`);
|
|
339
|
+
}
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
// Light op classification: archive transitions get their own kind;
|
|
343
|
+
// everything else is a generic update. Mirrors hybrid.
|
|
344
|
+
const opKind = patch.status === ARCHIVED_STATUS ? "archive" : "update";
|
|
345
|
+
await sync.appendOp(await this.#buildOp(opKind, id));
|
|
346
|
+
this.#schedulePush({
|
|
347
|
+
refname,
|
|
348
|
+
newOid,
|
|
349
|
+
expectedOld: currentOid,
|
|
350
|
+
isCreate: false,
|
|
351
|
+
});
|
|
352
|
+
this.#schedulePushState();
|
|
353
|
+
return next;
|
|
354
|
+
}
|
|
355
|
+
async delete(id) {
|
|
356
|
+
await this.#ensureInit();
|
|
357
|
+
const sync = this.#sync;
|
|
358
|
+
const refname = refnameFor(id);
|
|
359
|
+
const refs = await this.#git.forEachRef(refname);
|
|
360
|
+
const entry = refs.find((r) => r.refname === refname);
|
|
361
|
+
if (!entry)
|
|
362
|
+
throw new Error(`Task ${id} not found.`);
|
|
363
|
+
const oldOid = entry.oid;
|
|
364
|
+
try {
|
|
365
|
+
await this.#git.deleteRef(refname, oldOid);
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
if (isCASConflict(err)) {
|
|
369
|
+
throw new Error(`ordna: ${id} moved underneath us before delete. Another writer changed this task; pull and retry.`);
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
await sync.appendOp(await this.#buildOp("delete", id));
|
|
374
|
+
// Delete-push with lease so we don't clobber an in-flight remote
|
|
375
|
+
// update. Soft-fail like the other push paths — the local delete
|
|
376
|
+
// has already happened; a rejection just means the remote has
|
|
377
|
+
// diverged and the user needs to reconcile.
|
|
378
|
+
await this.#checkRemote();
|
|
379
|
+
if (this.#remoteExists) {
|
|
380
|
+
try {
|
|
381
|
+
await this.#git.run([
|
|
382
|
+
"push",
|
|
383
|
+
`--force-with-lease=${refname}:${oldOid}`,
|
|
384
|
+
"origin",
|
|
385
|
+
`:${refname}`,
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
+
console.error(`[ordna-namespace] delete-push for ${refname} failed: ${msg}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
this.#schedulePushState();
|
|
394
|
+
}
|
|
395
|
+
// ---------------- watch ----------------
|
|
396
|
+
watch(listener) {
|
|
397
|
+
this.#listeners.add(listener);
|
|
398
|
+
// Seed the snapshot once and start polling on the first
|
|
399
|
+
// subscription so the initial poll doesn't classify existing
|
|
400
|
+
// refs as `added`.
|
|
401
|
+
if (this.#listeners.size === 1 && this.#pollTimer === null) {
|
|
402
|
+
void this.#seedSnapshot().then(() => {
|
|
403
|
+
if (!this.#disposed && this.#listeners.size > 0) {
|
|
404
|
+
this.#schedulePoll();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return async () => {
|
|
409
|
+
this.#listeners.delete(listener);
|
|
410
|
+
if (this.#listeners.size === 0 && this.#pollTimer !== null) {
|
|
411
|
+
clearTimeout(this.#pollTimer);
|
|
412
|
+
this.#pollTimer = null;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
// ---------------- commit (deliberate no-op) ----------------
|
|
417
|
+
async commit(_message) {
|
|
418
|
+
// Tasks live outside the working tree; there's nothing to stage
|
|
419
|
+
// or commit. Auto-push handles sync silently. We could throw
|
|
420
|
+
// "namespace doesn't support commit," but `ordna commit` is
|
|
421
|
+
// muscle memory — making it a silent success matches the
|
|
422
|
+
// "working tree stays clean" model better.
|
|
423
|
+
}
|
|
424
|
+
// ---------------- fetch ----------------
|
|
425
|
+
async fetch() {
|
|
426
|
+
await this.#ensureInit();
|
|
427
|
+
const start = Date.now();
|
|
428
|
+
await this.#checkRemote();
|
|
429
|
+
if (!this.#remoteExists)
|
|
430
|
+
return { refsUpdated: 0, durationMs: 0 };
|
|
431
|
+
const before = await this.#snapshotRefs();
|
|
432
|
+
// Fetch task refs and the state ref. State ref may not yet exist
|
|
433
|
+
// on origin (mixed-version teams); ignore that failure.
|
|
434
|
+
await this.#git.fetchRefspec(NAMESPACE_FETCH_REFSPEC);
|
|
435
|
+
try {
|
|
436
|
+
await this.#git.fetchRefspec(STATE_FETCH_REFSPEC);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// remote doesn't have the state ref yet — fine
|
|
440
|
+
}
|
|
441
|
+
if (this.#sync)
|
|
442
|
+
this.#sync.invalidate();
|
|
443
|
+
const after = await this.#snapshotRefs();
|
|
444
|
+
const changed = this.#countRefDiff(before, after);
|
|
445
|
+
return { refsUpdated: changed, durationMs: Date.now() - start };
|
|
446
|
+
}
|
|
447
|
+
// ---------------- internals: push pipeline ----------------
|
|
448
|
+
#schedulePush(push) {
|
|
449
|
+
// Coalesce per refname: keep the original expectedOld (the value
|
|
450
|
+
// the remote has when we first scheduled), update newOid to the
|
|
451
|
+
// latest. isCreate stays as captured on first schedule — if a
|
|
452
|
+
// create+update happen back-to-back without a flush, the remote
|
|
453
|
+
// still sees "this ref didn't exist; now it does."
|
|
454
|
+
const existing = this.#pendingPushes.get(push.refname);
|
|
455
|
+
if (existing && existing.refname !== STATE_PUSH_SENTINEL) {
|
|
456
|
+
existing.newOid = push.newOid;
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
this.#pendingPushes.set(push.refname, { ...push });
|
|
460
|
+
}
|
|
461
|
+
this.#armPushTimer();
|
|
462
|
+
}
|
|
463
|
+
#schedulePushState() {
|
|
464
|
+
this.#pendingPushes.set(STATE_PUSH_SENTINEL, {
|
|
465
|
+
refname: STATE_PUSH_SENTINEL,
|
|
466
|
+
newOid: "",
|
|
467
|
+
expectedOld: "",
|
|
468
|
+
isCreate: false,
|
|
469
|
+
});
|
|
470
|
+
this.#armPushTimer();
|
|
471
|
+
}
|
|
472
|
+
#armPushTimer() {
|
|
473
|
+
if (this.#pushTimer)
|
|
474
|
+
clearTimeout(this.#pushTimer);
|
|
475
|
+
const t = setTimeout(() => {
|
|
476
|
+
this.#pushTimer = null;
|
|
477
|
+
void this.#drainPushes();
|
|
478
|
+
}, PUSH_DEBOUNCE_MS);
|
|
479
|
+
// Don't keep the Node process alive on the push timer alone —
|
|
480
|
+
// host owns lifetime, dispose() flushes anything pending.
|
|
481
|
+
t.unref?.();
|
|
482
|
+
this.#pushTimer = t;
|
|
483
|
+
}
|
|
484
|
+
async #drainPushes() {
|
|
485
|
+
if (this.#pushInFlight) {
|
|
486
|
+
this.#pushRetryPending = true;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
this.#pushInFlight = this.#runPushBatch().finally(() => {
|
|
490
|
+
this.#pushInFlight = null;
|
|
491
|
+
if (this.#pushRetryPending) {
|
|
492
|
+
this.#pushRetryPending = false;
|
|
493
|
+
void this.#drainPushes();
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async #runPushBatch() {
|
|
498
|
+
await this.#checkRemote();
|
|
499
|
+
if (!this.#remoteExists) {
|
|
500
|
+
this.#pendingPushes.clear();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Take the current batch; new schedules accumulate into the next.
|
|
504
|
+
const batch = Array.from(this.#pendingPushes.values());
|
|
505
|
+
this.#pendingPushes.clear();
|
|
506
|
+
// State ref push first — best-effort, force is fine since SyncRef
|
|
507
|
+
// has CAS-managed the ref in-process already.
|
|
508
|
+
const stateInBatch = batch.find((p) => p.refname === STATE_PUSH_SENTINEL);
|
|
509
|
+
if (stateInBatch) {
|
|
510
|
+
try {
|
|
511
|
+
await this.#git.pushRef(STATE_PUSH_REFSPEC);
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
515
|
+
console.error(`[ordna-namespace] state push failed: ${msg}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Task ref pushes with per-ref leases. We don't bail on first
|
|
519
|
+
// rejection — each ref is reconciled independently so a single
|
|
520
|
+
// collision doesn't block the rest.
|
|
521
|
+
for (const push of batch) {
|
|
522
|
+
if (push.refname === STATE_PUSH_SENTINEL)
|
|
523
|
+
continue;
|
|
524
|
+
await this.#pushTaskRef(push);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async #pushTaskRef(push) {
|
|
528
|
+
try {
|
|
529
|
+
await this.#git.pushRefWithLease(push.refname, push.newOid, push.expectedOld);
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
533
|
+
if (!isPushRejection(err)) {
|
|
534
|
+
console.error(`[ordna-namespace] push failed for ${push.refname}: ${msg}`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (push.isCreate && this.#autoRenumberOnConflict) {
|
|
538
|
+
await this.#reconcileCreateCollision(push);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
// Update collision (or auto-renumber disabled). Loud.
|
|
542
|
+
console.error(`[ordna-namespace] push rejected for ${push.refname}; remote has diverged. Run \`git fetch origin '+${push.refname}:${push.refname}'\` and reconcile manually.`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Push of a `create` was rejected because the remote already has
|
|
548
|
+
* this id (another offline writer landed it first). Recover by:
|
|
549
|
+
*
|
|
550
|
+
* 1. Fetching origin (so our local refs reflect the remote winner).
|
|
551
|
+
* 2. Reading the blob we tried to push (we still have the OID).
|
|
552
|
+
* 3. Allocating a fresh id via SyncRef.
|
|
553
|
+
* 4. Re-serialising with the new id, writing a new ref.
|
|
554
|
+
* 5. Cascading the rewrite through any local `depends_on`
|
|
555
|
+
* references to the old id.
|
|
556
|
+
* 6. Logging a `rename` op (with `renamedFrom`) in the audit log.
|
|
557
|
+
* 7. Scheduling the new refs for push.
|
|
558
|
+
* 8. Emitting a `renamed` event so the UI can toast + show the
|
|
559
|
+
* "previously known as X" banner.
|
|
560
|
+
*/
|
|
561
|
+
async #reconcileCreateCollision(push) {
|
|
562
|
+
const sync = this.#sync;
|
|
563
|
+
const oldId = idFromRefname(push.refname);
|
|
564
|
+
if (!oldId)
|
|
565
|
+
return;
|
|
566
|
+
try {
|
|
567
|
+
// 1. Fetch — our local copy of `push.refname` will be clobbered
|
|
568
|
+
// by the remote's value (that's fine; we abandon the local
|
|
569
|
+
// write of that ref and recreate under a new id).
|
|
570
|
+
await this.#git.fetchRefspec(NAMESPACE_FETCH_REFSPEC);
|
|
571
|
+
try {
|
|
572
|
+
await this.#git.fetchRefspec(STATE_FETCH_REFSPEC);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
// state ref may not exist on origin yet
|
|
576
|
+
}
|
|
577
|
+
sync.invalidate();
|
|
578
|
+
// 2. Read the blob we wanted to push. push.newOid is the OID
|
|
579
|
+
// we hashed locally; the blob still exists in .git/objects/
|
|
580
|
+
// even though the ref no longer points at it.
|
|
581
|
+
const ourBlob = await this.#git.catBlob(push.newOid);
|
|
582
|
+
const ourTask = parseTaskBytes(ourBlob, `ref:${push.refname}`);
|
|
583
|
+
delete ourTask.filePath;
|
|
584
|
+
// 3. Allocate a fresh id. After the fetch, the SyncRef cache
|
|
585
|
+
// has been invalidated and a new read reflects the merged
|
|
586
|
+
// remote+local view of `next_id`. The allocator may still
|
|
587
|
+
// hand us an id that's taken by a *different* local task
|
|
588
|
+
// (e.g. A had T-001 + T-002 local, T-001 collided, state
|
|
589
|
+
// says next_id=2 but T-002 is locally occupied). Loop until
|
|
590
|
+
// we get a genuinely free slot — burning ids is fine, they
|
|
591
|
+
// are cheap.
|
|
592
|
+
let newId = await sync.allocateNextId(this.config);
|
|
593
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
594
|
+
const existing = await this.#git.forEachRef(refnameFor(newId));
|
|
595
|
+
if (existing.find((r) => r.refname === refnameFor(newId)) === undefined) {
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
newId = await sync.allocateNextId(this.config);
|
|
599
|
+
}
|
|
600
|
+
// 4. Re-serialise with the new id.
|
|
601
|
+
const renamed = {
|
|
602
|
+
...ourTask,
|
|
603
|
+
id: newId,
|
|
604
|
+
updated_at: today(),
|
|
605
|
+
};
|
|
606
|
+
const renamedSerialized = serializeTask(renamed, this.config.schema);
|
|
607
|
+
renamed.rawContent = renamedSerialized;
|
|
608
|
+
const renamedOid = await this.#git.hashObject(renamedSerialized);
|
|
609
|
+
await this.#git.updateRef(refnameFor(newId), renamedOid, "");
|
|
610
|
+
// 5. Cascade. Any local task whose depends_on referenced oldId
|
|
611
|
+
// gets rewritten to newId. Naive sweep — accepts rare false
|
|
612
|
+
// positives where a remote teammate's task coincidentally
|
|
613
|
+
// depended on the colliding id (their content is untouched
|
|
614
|
+
// locally, so the cascade only catches genuinely-local edits).
|
|
615
|
+
await this.#cascadeDependsOnRewrite(oldId, newId);
|
|
616
|
+
// 6. Audit log.
|
|
617
|
+
await sync.appendOp({
|
|
618
|
+
ts: nowIso(),
|
|
619
|
+
actor: await this.#resolveActor(),
|
|
620
|
+
op: "rename",
|
|
621
|
+
id: newId,
|
|
622
|
+
renamedFrom: oldId,
|
|
623
|
+
});
|
|
624
|
+
// 7. Schedule pushes.
|
|
625
|
+
this.#schedulePush({
|
|
626
|
+
refname: refnameFor(newId),
|
|
627
|
+
newOid: renamedOid,
|
|
628
|
+
expectedOld: "",
|
|
629
|
+
isCreate: true,
|
|
630
|
+
});
|
|
631
|
+
this.#schedulePushState();
|
|
632
|
+
// 8. Notify watchers.
|
|
633
|
+
this.#emit({ type: "renamed", oldId, newId, task: renamed });
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
637
|
+
console.error(`[ordna-namespace] reconcile failed for ${push.refname}: ${msg}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async #cascadeDependsOnRewrite(oldId, newId) {
|
|
641
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
642
|
+
for (const { refname, oid } of refs) {
|
|
643
|
+
const id = idFromRefname(refname);
|
|
644
|
+
if (!id || id === newId)
|
|
645
|
+
continue;
|
|
646
|
+
try {
|
|
647
|
+
const raw = await this.#git.catBlob(oid);
|
|
648
|
+
const task = parseTaskBytes(raw, `ref:${refname}`);
|
|
649
|
+
delete task.filePath;
|
|
650
|
+
if (!task.depends_on.includes(oldId))
|
|
651
|
+
continue;
|
|
652
|
+
const next = {
|
|
653
|
+
...task,
|
|
654
|
+
depends_on: task.depends_on.map((d) => d === oldId ? newId : d),
|
|
655
|
+
updated_at: today(),
|
|
656
|
+
};
|
|
657
|
+
const serialized = serializeTask(next, this.config.schema);
|
|
658
|
+
next.rawContent = serialized;
|
|
659
|
+
const newOid = await this.#git.hashObject(serialized);
|
|
660
|
+
try {
|
|
661
|
+
await this.#git.updateRef(refname, newOid, oid);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
// Ref moved underneath the cascade. Skip — the next
|
|
665
|
+
// pass (e.g. on the next mutation) will pick it up
|
|
666
|
+
// if needed; we don't loop here to avoid contention.
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
this.#schedulePush({
|
|
670
|
+
refname,
|
|
671
|
+
newOid,
|
|
672
|
+
expectedOld: oid,
|
|
673
|
+
isCreate: false,
|
|
674
|
+
});
|
|
675
|
+
this.#emit({ type: "changed", task: next });
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// skip unreadable tasks
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async #flushPushes() {
|
|
683
|
+
// Cancel the debounce timer so we don't accidentally fire a
|
|
684
|
+
// duplicate drain alongside ours.
|
|
685
|
+
if (this.#pushTimer) {
|
|
686
|
+
clearTimeout(this.#pushTimer);
|
|
687
|
+
this.#pushTimer = null;
|
|
688
|
+
}
|
|
689
|
+
// Kick off a drain now if there's anything queued and nothing
|
|
690
|
+
// already running. Otherwise the in-flight drain (or the empty
|
|
691
|
+
// state) handles it.
|
|
692
|
+
if (this.#pendingPushes.size > 0 && !this.#pushInFlight) {
|
|
693
|
+
void this.#drainPushes();
|
|
694
|
+
}
|
|
695
|
+
// Wait until the pipeline is fully quiet — drains may chain via
|
|
696
|
+
// the retry-pending flag (e.g. a reconcile schedules a new push
|
|
697
|
+
// while the previous batch is running).
|
|
698
|
+
while (this.#pushInFlight) {
|
|
699
|
+
await this.#pushInFlight;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// ---------------- internals: rename history ----------------
|
|
703
|
+
/**
|
|
704
|
+
* Walk the audit log in reverse and return a map of `currentId →
|
|
705
|
+
* mostRecentPreviousId` for every renamed task. Cheap because the
|
|
706
|
+
* state blob is cached in SyncRef; we re-walk only when the cache
|
|
707
|
+
* has been invalidated (by a fetch or a CAS conflict).
|
|
708
|
+
*/
|
|
709
|
+
async #buildRenamedFromMap() {
|
|
710
|
+
const map = new Map();
|
|
711
|
+
if (!this.#sync)
|
|
712
|
+
return map;
|
|
713
|
+
try {
|
|
714
|
+
const state = await this.#sync.read();
|
|
715
|
+
// Walk in reverse so the first hit per id is the most recent.
|
|
716
|
+
for (let i = state.ops.length - 1; i >= 0; i--) {
|
|
717
|
+
const op = state.ops[i];
|
|
718
|
+
if (!op || op.op !== "rename" || !op.renamedFrom)
|
|
719
|
+
continue;
|
|
720
|
+
if (map.has(op.id))
|
|
721
|
+
continue;
|
|
722
|
+
map.set(op.id, op.renamedFrom);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// State blob unreadable — return empty map; banner just doesn't show.
|
|
727
|
+
}
|
|
728
|
+
return map;
|
|
729
|
+
}
|
|
730
|
+
async #lookupRenamedFrom(id) {
|
|
731
|
+
if (!this.#sync)
|
|
732
|
+
return null;
|
|
733
|
+
try {
|
|
734
|
+
const state = await this.#sync.read();
|
|
735
|
+
for (let i = state.ops.length - 1; i >= 0; i--) {
|
|
736
|
+
const op = state.ops[i];
|
|
737
|
+
if (!op || op.op !== "rename" || op.id !== id)
|
|
738
|
+
continue;
|
|
739
|
+
return op.renamedFrom ?? null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// fall through
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
// ---------------- internals: actor / audit op builder ----------------
|
|
748
|
+
async #buildOp(op, id) {
|
|
749
|
+
return {
|
|
750
|
+
ts: nowIso(),
|
|
751
|
+
actor: await this.#resolveActor(),
|
|
752
|
+
op,
|
|
753
|
+
id,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
async #resolveActor() {
|
|
757
|
+
if (this.#cachedActor !== null)
|
|
758
|
+
return this.#cachedActor;
|
|
759
|
+
const fromGit = await this.#git.userEmail();
|
|
760
|
+
if (fromGit) {
|
|
761
|
+
this.#cachedActor = fromGit;
|
|
762
|
+
return fromGit;
|
|
763
|
+
}
|
|
764
|
+
const fromEnv = process.env.ORDNA_ACTOR;
|
|
765
|
+
if (fromEnv && fromEnv.trim().length > 0) {
|
|
766
|
+
this.#cachedActor = fromEnv.trim();
|
|
767
|
+
return this.#cachedActor;
|
|
768
|
+
}
|
|
769
|
+
this.#cachedActor = "unknown";
|
|
770
|
+
return this.#cachedActor;
|
|
771
|
+
}
|
|
772
|
+
// ---------------- internals: watcher poll ----------------
|
|
773
|
+
async #seedSnapshot() {
|
|
774
|
+
await this.#ensureInit();
|
|
775
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
776
|
+
this.#lastSnapshot.clear();
|
|
777
|
+
for (const { refname, oid } of refs) {
|
|
778
|
+
this.#lastSnapshot.set(refname, oid);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
#schedulePoll() {
|
|
782
|
+
if (this.#disposed)
|
|
783
|
+
return;
|
|
784
|
+
const t = setTimeout(() => {
|
|
785
|
+
this.#pollTimer = null;
|
|
786
|
+
void this.#poll();
|
|
787
|
+
}, this.#pollIntervalMs);
|
|
788
|
+
t.unref?.();
|
|
789
|
+
this.#pollTimer = t;
|
|
790
|
+
}
|
|
791
|
+
async #poll() {
|
|
792
|
+
if (this.#disposed || this.#listeners.size === 0)
|
|
793
|
+
return;
|
|
794
|
+
try {
|
|
795
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
796
|
+
const next = new Map();
|
|
797
|
+
for (const { refname, oid } of refs)
|
|
798
|
+
next.set(refname, oid);
|
|
799
|
+
await this.#diffAndEmit(this.#lastSnapshot, next);
|
|
800
|
+
this.#lastSnapshot = next;
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
804
|
+
console.error(`[ordna-namespace] poll failed: ${msg}`);
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
if (!this.#disposed && this.#listeners.size > 0) {
|
|
808
|
+
this.#schedulePoll();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
async #diffAndEmit(prev, next) {
|
|
813
|
+
for (const [refname, oid] of next) {
|
|
814
|
+
if (!prev.has(refname)) {
|
|
815
|
+
const task = await this.#parseRef(refname, oid);
|
|
816
|
+
if (task)
|
|
817
|
+
this.#emit({ type: "added", task });
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (prev.get(refname) !== oid) {
|
|
821
|
+
const task = await this.#parseRef(refname, oid);
|
|
822
|
+
if (task)
|
|
823
|
+
this.#emit({ type: "changed", task });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
for (const [refname] of prev) {
|
|
827
|
+
if (!next.has(refname)) {
|
|
828
|
+
this.#emit({ type: "removed", filePath: refname });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async #parseRef(refname, oid) {
|
|
833
|
+
try {
|
|
834
|
+
const raw = await this.#git.catBlob(oid);
|
|
835
|
+
const task = parseTaskBytes(raw, `ref:${refname}`);
|
|
836
|
+
delete task.filePath;
|
|
837
|
+
return task;
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
#emit(event) {
|
|
844
|
+
for (const listener of this.#listeners) {
|
|
845
|
+
try {
|
|
846
|
+
listener(event);
|
|
847
|
+
}
|
|
848
|
+
catch (err) {
|
|
849
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
850
|
+
console.error(`[ordna-namespace] listener threw: ${msg}`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
async #checkRemote() {
|
|
855
|
+
if (this.#remoteChecked)
|
|
856
|
+
return;
|
|
857
|
+
this.#remoteChecked = true;
|
|
858
|
+
this.#remoteExists = await this.#git.hasRemote();
|
|
859
|
+
}
|
|
860
|
+
async #snapshotRefs() {
|
|
861
|
+
const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
|
|
862
|
+
const map = new Map();
|
|
863
|
+
for (const { refname, oid } of refs)
|
|
864
|
+
map.set(refname, oid);
|
|
865
|
+
return map;
|
|
866
|
+
}
|
|
867
|
+
#countRefDiff(prev, next) {
|
|
868
|
+
let changed = 0;
|
|
869
|
+
for (const [refname, oid] of next) {
|
|
870
|
+
if (prev.get(refname) !== oid)
|
|
871
|
+
changed++;
|
|
872
|
+
}
|
|
873
|
+
for (const refname of prev.keys()) {
|
|
874
|
+
if (!next.has(refname))
|
|
875
|
+
changed++;
|
|
876
|
+
}
|
|
877
|
+
return changed;
|
|
878
|
+
}
|
|
879
|
+
#scheduleAutoFetch() {
|
|
880
|
+
if (this.#disposed || this.#autoFetchIntervalMs <= 0)
|
|
881
|
+
return;
|
|
882
|
+
const t = setTimeout(() => {
|
|
883
|
+
this.#autoFetchTimer = null;
|
|
884
|
+
void this.#runAutoFetch();
|
|
885
|
+
}, this.#autoFetchIntervalMs);
|
|
886
|
+
t.unref?.();
|
|
887
|
+
this.#autoFetchTimer = t;
|
|
888
|
+
}
|
|
889
|
+
async #runAutoFetch() {
|
|
890
|
+
if (this.#disposed)
|
|
891
|
+
return;
|
|
892
|
+
try {
|
|
893
|
+
this.#autoFetchInFlight = this.fetch();
|
|
894
|
+
await this.#autoFetchInFlight;
|
|
895
|
+
}
|
|
896
|
+
catch (err) {
|
|
897
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
898
|
+
console.error(`[ordna-namespace] auto-fetch failed: ${msg}`);
|
|
899
|
+
}
|
|
900
|
+
finally {
|
|
901
|
+
this.#autoFetchInFlight = null;
|
|
902
|
+
if (!this.#disposed)
|
|
903
|
+
this.#scheduleAutoFetch();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
//# sourceMappingURL=namespace.js.map
|