@avee1234/worklease 0.1.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.
@@ -0,0 +1,233 @@
1
+ // worklease — the append-only registry store.
2
+ //
3
+ // A registry is an append-only JSONL file (one JSON record per line). This
4
+ // module is the single home for the store: a small set of *pure* functions
5
+ // (canonical serialization, content-hash ids, log resolution) plus a thin I/O
6
+ // layer (`appendRecord`, `loadRegistry`) that keeps every filesystem access in
7
+ // one place. The design is deliberately lock-free — writes are single-line
8
+ // `O_APPEND` writes, reads fold the whole log into the current claim array, and
9
+ // every record self-identifies by a content hash, so concurrent or duplicated
10
+ // appends resolve cleanly instead of conflicting. Node's built-in `crypto` is
11
+ // the only "dependency"; there are zero runtime packages.
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+
17
+ // --- pure core -------------------------------------------------------------
18
+
19
+ // canonicalize(record) → deterministic JSON string with sorted keys and no
20
+ // incidental whitespace, over the record EXCLUDING its own `id`. Recurses so
21
+ // key ordering can never perturb the digest at any depth. Used only as the hash
22
+ // pre-image, so it does not need to round-trip to a value.
23
+ export function canonicalize(record) {
24
+ const { id: _id, ...rest } = record;
25
+ return stableStringify(rest);
26
+ }
27
+
28
+ function stableStringify(value) {
29
+ if (Array.isArray(value)) {
30
+ return "[" + value.map(stableStringify).join(",") + "]";
31
+ }
32
+ if (value !== null && typeof value === "object") {
33
+ const keys = Object.keys(value).sort();
34
+ return (
35
+ "{" +
36
+ keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") +
37
+ "}"
38
+ );
39
+ }
40
+ return JSON.stringify(value);
41
+ }
42
+
43
+ // computeRecordId(record) → the sha256 content hash of the record (its `id`
44
+ // excluded). Deterministic and content-addressed: identical content ⇒ identical
45
+ // id, so a duplicated append is idempotent on read and a tampered line no longer
46
+ // matches its own id. `claim` (#2) uses this same helper so a claim's `id` IS its
47
+ // content hash — one shared implementation across every record type.
48
+ export function computeRecordId(record) {
49
+ return createHash("sha256").update(canonicalize(record)).digest("hex");
50
+ }
51
+
52
+ // shortId(id) → first 8 hex chars, the compact id `list` shows and `release`
53
+ // accepts as a prefix.
54
+ export function shortId(id) {
55
+ return typeof id === "string" ? id.slice(0, 8) : String(id);
56
+ }
57
+
58
+ // formatRelative(expires, now) → a short human relative expiry: "in 40s",
59
+ // "in 12m", "in 3h", or "expired" (also "unknown" for an unparseable value).
60
+ export function formatRelative(expires, now = Date.now()) {
61
+ const ms = Date.parse(expires);
62
+ if (Number.isNaN(ms)) return "unknown";
63
+ const delta = ms - now;
64
+ if (delta <= 0) return "expired";
65
+ const s = Math.round(delta / 1000);
66
+ if (s < 60) return `in ${s}s`;
67
+ const m = Math.round(s / 60);
68
+ if (m < 60) return `in ${m}m`;
69
+ return `in ${Math.round(m / 60)}h`;
70
+ }
71
+
72
+ // listActive(claims) → the subset whose effective status is "active". Pure
73
+ // selector kept here for direct unit testing.
74
+ export function listActive(claims) {
75
+ return claims.filter((c) => c.status === "active");
76
+ }
77
+
78
+ // resolveRecords(records, { now, expire }) → { claims, notes }
79
+ //
80
+ // Folds an already-parsed append log (order = append order) into the current
81
+ // claim array. Pure and total: `now` (epoch ms) is injected so expiry is
82
+ // deterministic, and no bad field value throws. Nothing is written back — every
83
+ // derived status is computed here at read time.
84
+ //
85
+ // 1. Integrity filter — drop any record whose `id` doesn't equal its own
86
+ // content hash (tamper/corruption), with a note. One bad line never
87
+ // discards the rest of the registry.
88
+ // 2. Fold claims — latest claim record per `id` wins (content-addressed, so
89
+ // normally identical; tolerant of a re-append → idempotent).
90
+ // 3. Apply releases — a `release` record moves its `claim_id` to `released`;
91
+ // an unknown `claim_id` is noted and ignored; a releaser who isn't the
92
+ // holder is noted (advisory ownership hint).
93
+ // 4. Derive TTL expiry — an `active` claim whose `expires <= now` becomes
94
+ // effective status `expired` (derived, with a note); released claims stay
95
+ // released. Skipped when `expire` is false, so the stored claim state
96
+ // (active/released, no wall-clock decay) is returned — `conformance` needs
97
+ // this because it audits *after* short TTLs have elapsed and does its own
98
+ // temporal reasoning against each merge's `at`.
99
+ //
100
+ // Returns the claims sorted by `expires` ascending plus the collected notes.
101
+ export function resolveRecords(records, opts = {}) {
102
+ const { now = Date.now(), expire = true } = opts;
103
+ const notes = [];
104
+
105
+ // 1. Integrity filter (also drops non-objects, which can't self-hash).
106
+ const valid = [];
107
+ records.forEach((r, i) => {
108
+ if (r === null || typeof r !== "object" || Array.isArray(r)) {
109
+ notes.push(`skipped record ${i}: not an object`);
110
+ return;
111
+ }
112
+ if (r.id !== computeRecordId(r)) {
113
+ notes.push(`skipped record ${i}: id/content mismatch`);
114
+ return;
115
+ }
116
+ valid.push(r);
117
+ });
118
+
119
+ // 2. Fold claims / collect releases (a record is a release iff type ===
120
+ // "release"; a claim iff no type or type === "claim"; anything else is
121
+ // forward-compat noise, skipped).
122
+ const claims = new Map();
123
+ const releases = [];
124
+ for (const r of valid) {
125
+ const type = r.type == null ? "claim" : r.type;
126
+ if (type === "claim") {
127
+ claims.set(r.id, { ...r });
128
+ } else if (type === "release") {
129
+ releases.push(r);
130
+ } else {
131
+ notes.push(`skipped record ${shortId(r.id)}: unknown type "${r.type}"`);
132
+ }
133
+ }
134
+
135
+ // 3. Apply releases.
136
+ for (const rel of releases) {
137
+ const claim = claims.get(rel.claim_id);
138
+ if (!claim) {
139
+ notes.push(`release for unknown claim_id ${shortId(rel.claim_id)} ignored`);
140
+ continue;
141
+ }
142
+ claim.status = "released";
143
+ claim.released_by = rel.agent;
144
+ claim.released_at = rel.at;
145
+ if (rel.agent !== claim.agent) {
146
+ notes.push(
147
+ `claim ${shortId(claim.id)} released by ${rel.agent}, held by ${claim.agent}`
148
+ );
149
+ }
150
+ }
151
+
152
+ // 4. Derive TTL expiry (a claim exactly at expires === now counts as expired).
153
+ for (const claim of expire ? claims.values() : []) {
154
+ if (claim.status === "active" && claim.expires != null) {
155
+ const exp = Date.parse(claim.expires);
156
+ if (!Number.isNaN(exp) && exp <= now) {
157
+ claim.status = "expired";
158
+ notes.push(`claim ${shortId(claim.id)} expired at ${claim.expires}`);
159
+ }
160
+ }
161
+ }
162
+
163
+ const resolved = [...claims.values()].sort(byExpires);
164
+ return { claims: resolved, notes };
165
+ }
166
+
167
+ // Sort by `expires` ascending; unparseable/absent expiries sort last, stably.
168
+ function byExpires(a, b) {
169
+ const ea = Date.parse(a.expires);
170
+ const eb = Date.parse(b.expires);
171
+ const na = Number.isNaN(ea);
172
+ const nb = Number.isNaN(eb);
173
+ if (na && nb) return 0;
174
+ if (na) return 1;
175
+ if (nb) return -1;
176
+ return ea - eb;
177
+ }
178
+
179
+ // --- I/O layer -------------------------------------------------------------
180
+
181
+ // defaultRegistryPath(cwd) → the ONE place the default registry location is
182
+ // defined; `check`, `list`, and `release` all call it. `WORKLEASE_REGISTRY`
183
+ // overrides, else a git-tracked `.worklease/registry.jsonl` at the repo root.
184
+ export function defaultRegistryPath(cwd = process.cwd()) {
185
+ return process.env.WORKLEASE_REGISTRY || join(cwd, ".worklease", "registry.jsonl");
186
+ }
187
+
188
+ // appendRecord(path, record) → the stored record (with its content-hash `id`).
189
+ // Assigns `id` if absent, creates the parent directory, then appends exactly one
190
+ // JSON line terminated by "\n" with the `"a"` flag (O_APPEND). Existing lines are
191
+ // never rewritten — this is the whole safety story. Used by `release` here and by
192
+ // `claim` (#2).
193
+ export function appendRecord(path, record) {
194
+ const stored = record.id ? record : { ...record, id: computeRecordId(record) };
195
+ mkdirSync(dirname(path), { recursive: true });
196
+ appendFileSync(path, JSON.stringify(stored) + "\n");
197
+ return stored;
198
+ }
199
+
200
+ // loadRegistry(path, { now, expire }) → { claims, notes }
201
+ //
202
+ // Reads the JSONL file (missing file → empty registry, no throw), parses each
203
+ // non-blank line tolerantly (a line that won't parse is dropped with a note,
204
+ // never aborting the load), and returns the resolved current registry via
205
+ // `resolveRecords`. `expire` (default true) forwards to `resolveRecords`; pass
206
+ // false to skip wall-clock TTL decay and see the stored claim state. Replaces
207
+ // #3's interim reader.
208
+ export function loadRegistry(path, opts = {}) {
209
+ const { now = Date.now(), expire = true } = opts;
210
+
211
+ let raw;
212
+ try {
213
+ raw = readFileSync(path, "utf8");
214
+ } catch (e) {
215
+ if (e && e.code === "ENOENT") return { claims: [], notes: [] };
216
+ throw e;
217
+ }
218
+
219
+ const parsed = [];
220
+ const parseNotes = [];
221
+ raw.split("\n").forEach((line, i) => {
222
+ const trimmed = line.trim();
223
+ if (!trimmed) return;
224
+ try {
225
+ parsed.push(JSON.parse(trimmed));
226
+ } catch {
227
+ parseNotes.push(`skipped unparseable line ${i + 1}`);
228
+ }
229
+ });
230
+
231
+ const resolved = resolveRecords(parsed, { now, expire });
232
+ return { claims: resolved.claims, notes: [...parseNotes, ...resolved.notes] };
233
+ }
package/src/schema.js ADDED
@@ -0,0 +1,230 @@
1
+ // worklease — claim + registry schema and validators.
2
+ //
3
+ // Pure, zero-dependency validators for the open `claim` shape and a registry
4
+ // (array of claims). Neither function throws on bad input; both return
5
+ // `{ valid, errors }` and collect *every* violation (no short-circuit) so a
6
+ // harness or human can fix everything in one pass.
7
+ //
8
+ // Error = { path: string, code: string, message: string }
9
+ // path — dot/bracket path to the offending value ("globs[0]", "[2].ttl_seconds",
10
+ // or "" for the whole object).
11
+ // code — a stable machine-readable code from ERROR_CODES.
12
+ // message — one-line human explanation.
13
+
14
+ export const STATUSES = ["active", "released", "expired"];
15
+
16
+ // The exact set of allowed top-level claim fields, in canonical order.
17
+ export const CLAIM_FIELDS = [
18
+ "id",
19
+ "agent",
20
+ "globs",
21
+ "intent",
22
+ "ttl_seconds",
23
+ "created",
24
+ "expires",
25
+ "status",
26
+ ];
27
+
28
+ export const ERROR_CODES = {
29
+ MISSING_FIELD: "MISSING_FIELD",
30
+ UNKNOWN_FIELD: "UNKNOWN_FIELD",
31
+ WRONG_TYPE: "WRONG_TYPE",
32
+ NOT_OBJECT: "NOT_OBJECT",
33
+ NOT_ARRAY: "NOT_ARRAY",
34
+ EMPTY_STRING: "EMPTY_STRING",
35
+ EMPTY_ARRAY: "EMPTY_ARRAY",
36
+ INVALID_ENUM: "INVALID_ENUM",
37
+ NOT_POSITIVE_INT: "NOT_POSITIVE_INT",
38
+ INVALID_ISO8601: "INVALID_ISO8601",
39
+ EXPIRES_MISMATCH: "EXPIRES_MISMATCH",
40
+ INVALID_GLOB: "INVALID_GLOB",
41
+ DUPLICATE_ID: "DUPLICATE_ID",
42
+ };
43
+
44
+ // Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z. The regex gates the format
45
+ // (UTC `Z` only, no offsets); Date.parse gates real-calendar validity so
46
+ // impossible dates like 2026-13-40T00:00:00Z are rejected.
47
+ const ISO8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
48
+
49
+ // Unsupported glob metacharacters for the documented v0.1 subset. Supported
50
+ // tokens are `**`, `*`, `/`, and literal path characters; `? [ ] { }` are
51
+ // rejected so a malformed glob is caught at claim time rather than silently
52
+ // mis-matching later in `check`.
53
+ const UNSUPPORTED_GLOB_CHARS = /[?[\]{}]/;
54
+
55
+ export function isIso8601Utc(s) {
56
+ if (typeof s !== "string" || !ISO8601_UTC.test(s)) return false;
57
+ const ms = Date.parse(s);
58
+ if (Number.isNaN(ms)) return false;
59
+ // Date.parse silently rolls over impossible calendar dates (e.g.
60
+ // 2026-02-30 → Mar 2, 2026-04-31 → May 1) instead of returning NaN, so a
61
+ // format-valid but nonexistent date would slip through. Round-trip the
62
+ // parsed value and require the calendar portion to match the input.
63
+ return new Date(ms).toISOString().slice(0, 10) === s.slice(0, 10);
64
+ }
65
+
66
+ export function isAllowedGlob(s) {
67
+ return typeof s === "string" && s.length > 0 && !UNSUPPORTED_GLOB_CHARS.test(s);
68
+ }
69
+
70
+ function err(path, code, message) {
71
+ return { path, code, message };
72
+ }
73
+
74
+ function isPlainObject(v) {
75
+ return typeof v === "object" && v !== null && !Array.isArray(v);
76
+ }
77
+
78
+ function isNonEmptyString(v) {
79
+ return typeof v === "string" && v.trim().length > 0;
80
+ }
81
+
82
+ // Validate a single string field: present-ness is checked by the caller; here
83
+ // we type/emptiness-check a value that exists.
84
+ function checkStringField(errors, obj, field) {
85
+ const v = obj[field];
86
+ if (typeof v !== "string") {
87
+ errors.push(err(field, ERROR_CODES.WRONG_TYPE, `${field} must be a string`));
88
+ } else if (v.trim().length === 0) {
89
+ errors.push(err(field, ERROR_CODES.EMPTY_STRING, `${field} must not be empty`));
90
+ }
91
+ }
92
+
93
+ export function validateClaim(obj) {
94
+ // 1. Must be a non-null plain object.
95
+ if (!isPlainObject(obj)) {
96
+ return {
97
+ valid: false,
98
+ errors: [err("", ERROR_CODES.NOT_OBJECT, "claim must be a JSON object")],
99
+ };
100
+ }
101
+
102
+ const errors = [];
103
+
104
+ // 2. Required fields present.
105
+ for (const field of CLAIM_FIELDS) {
106
+ if (!(field in obj)) {
107
+ errors.push(err(field, ERROR_CODES.MISSING_FIELD, `${field} is required`));
108
+ }
109
+ }
110
+
111
+ // 3. Unknown top-level fields.
112
+ for (const key of Object.keys(obj)) {
113
+ if (!CLAIM_FIELDS.includes(key)) {
114
+ errors.push(err(key, ERROR_CODES.UNKNOWN_FIELD, `unknown field: ${key}`));
115
+ }
116
+ }
117
+
118
+ // 4. Per-field type/shape (only for fields that are present).
119
+ if ("id" in obj) checkStringField(errors, obj, "id");
120
+ if ("agent" in obj) checkStringField(errors, obj, "agent");
121
+ if ("intent" in obj) checkStringField(errors, obj, "intent");
122
+
123
+ if ("globs" in obj) {
124
+ const globs = obj.globs;
125
+ if (!Array.isArray(globs)) {
126
+ errors.push(err("globs", ERROR_CODES.WRONG_TYPE, "globs must be an array"));
127
+ } else if (globs.length === 0) {
128
+ errors.push(err("globs", ERROR_CODES.EMPTY_ARRAY, "globs must have at least one entry"));
129
+ } else {
130
+ globs.forEach((g, i) => {
131
+ if (typeof g !== "string") {
132
+ errors.push(err(`globs[${i}]`, ERROR_CODES.WRONG_TYPE, "glob must be a string"));
133
+ } else if (g.length === 0) {
134
+ errors.push(err(`globs[${i}]`, ERROR_CODES.EMPTY_STRING, "glob must not be empty"));
135
+ } else if (!isAllowedGlob(g)) {
136
+ errors.push(
137
+ err(
138
+ `globs[${i}]`,
139
+ ERROR_CODES.INVALID_GLOB,
140
+ "glob uses unsupported metacharacters (? [ ] { } are not allowed)"
141
+ )
142
+ );
143
+ }
144
+ });
145
+ }
146
+ }
147
+
148
+ if ("ttl_seconds" in obj) {
149
+ const t = obj.ttl_seconds;
150
+ if (typeof t !== "number" || !Number.isInteger(t) || t <= 0) {
151
+ errors.push(
152
+ err("ttl_seconds", ERROR_CODES.NOT_POSITIVE_INT, "ttl_seconds must be an integer > 0")
153
+ );
154
+ }
155
+ }
156
+
157
+ if ("created" in obj && !isIso8601Utc(obj.created)) {
158
+ errors.push(err("created", ERROR_CODES.INVALID_ISO8601, "created must be ISO 8601 UTC (…Z)"));
159
+ }
160
+ if ("expires" in obj && !isIso8601Utc(obj.expires)) {
161
+ errors.push(err("expires", ERROR_CODES.INVALID_ISO8601, "expires must be ISO 8601 UTC (…Z)"));
162
+ }
163
+
164
+ if ("status" in obj && !STATUSES.includes(obj.status)) {
165
+ errors.push(
166
+ err("status", ERROR_CODES.INVALID_ENUM, `status must be one of: ${STATUSES.join(", ")}`)
167
+ );
168
+ }
169
+
170
+ // 5. Cross-field: expires === created + ttl_seconds (only when all three are
171
+ // individually valid, so we don't pile a mismatch on top of a format error).
172
+ if (
173
+ isIso8601Utc(obj.created) &&
174
+ isIso8601Utc(obj.expires) &&
175
+ typeof obj.ttl_seconds === "number" &&
176
+ Number.isInteger(obj.ttl_seconds) &&
177
+ obj.ttl_seconds > 0
178
+ ) {
179
+ if (Date.parse(obj.expires) !== Date.parse(obj.created) + obj.ttl_seconds * 1000) {
180
+ errors.push(
181
+ err(
182
+ "expires",
183
+ ERROR_CODES.EXPIRES_MISMATCH,
184
+ "expires must equal created + ttl_seconds"
185
+ )
186
+ );
187
+ }
188
+ }
189
+
190
+ return { valid: errors.length === 0, errors };
191
+ }
192
+
193
+ export function validateRegistry(arr) {
194
+ // 1. Must be an array.
195
+ if (!Array.isArray(arr)) {
196
+ return {
197
+ valid: false,
198
+ errors: [err("", ERROR_CODES.NOT_ARRAY, "registry must be a JSON array")],
199
+ };
200
+ }
201
+
202
+ const errors = [];
203
+
204
+ // 2. Per-element validation, re-prefixing each error path with [i].
205
+ arr.forEach((claim, i) => {
206
+ const result = validateClaim(claim);
207
+ for (const e of result.errors) {
208
+ const path = e.path === "" ? `[${i}]` : `[${i}].${e.path}`;
209
+ errors.push(err(path, e.code, e.message));
210
+ }
211
+ });
212
+
213
+ // 3. Duplicate id detection among structurally-valid claims. A duplicate id
214
+ // signals corruption or a double-append; flag every occurrence after the
215
+ // first at [i].id.
216
+ const seen = new Set();
217
+ arr.forEach((claim, i) => {
218
+ if (isPlainObject(claim) && isNonEmptyString(claim.id)) {
219
+ if (seen.has(claim.id)) {
220
+ errors.push(
221
+ err(`[${i}].id`, ERROR_CODES.DUPLICATE_ID, `duplicate id: ${claim.id}`)
222
+ );
223
+ } else {
224
+ seen.add(claim.id);
225
+ }
226
+ }
227
+ });
228
+
229
+ return { valid: errors.length === 0, errors };
230
+ }