@juli-os/ledger 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ The L1 layer of the juli open-source ladder: **the append-only ledger of record
8
8
  |---|---|---|
9
9
  | Workflow lifecycle | `createLedgerStore(path)` → `createWorkflow / getWorkflow / listWorkflows / setWorkflowStatus / createStep / insertStepsAt …` | Durable jobs + steps + statuses in SQLite, with dynamic mid-flight step insertion (single transaction, tail renumbered) |
10
10
  | Conversation threading | `latestByMessageId(msgId)` / `recentBySender(from, since)` | Reply-chain matching for email bots and support inboxes — message-id causal anchor + sender/time-window candidate narrowing |
11
- | Case files | `emptyCaseFile() / loadCaseFile / saveCaseFile` | A per-job audit dossier: original → extract → executions → draft → decisions → artifacts |
11
+ | Case files | `emptyCaseFile(now) / loadCaseFile / saveCaseFile` | A per-job audit dossier: original → extract → executions → draft → decisions → artifacts |
12
12
  | Usage & cost | `createUsageStore(path)` → `record / stats / timeline / byLocalDay`, `priceUsage()` | Token accounting per model / day / session with built-in pricing |
13
13
 
14
14
  ## Peel boundary
@@ -16,6 +16,7 @@ export interface Workflow {
16
16
  readonly project: string;
17
17
  readonly path: string;
18
18
  readonly threadId: string | null;
19
+ readonly tenant: string;
19
20
  readonly meta: JsonRecord;
20
21
  readonly createdAt: string;
21
22
  readonly updatedAt: string;
package/dist/index.js CHANGED
@@ -110,7 +110,7 @@ var parseCaseFile = (raw, now) => {
110
110
  decisions: pick(arr("decisions"), (d) => "action" in d),
111
111
  references: arr("references").filter((x) => typeof x === "string"),
112
112
  artifacts: pick(arr("artifacts"), (a) => "id" in a),
113
- updatedAt: str2("updated_at") || base.updatedAt
113
+ updatedAt: str2("updatedAt") || str2("updated_at") || base.updatedAt
114
114
  };
115
115
  };
116
116
  var serializeCaseFile = (cf) => JSON.stringify(cf);
@@ -240,7 +240,8 @@ var rowToWorkflow = (r) => ({
240
240
  session: str(r["session"]),
241
241
  project: str(r["project"]),
242
242
  path: str(r["path"]),
243
- threadId: r["thread_id"] === null || r["thread_id"] === void 0 ? null : str(r["thread_id"]),
243
+ threadId: r["thread_id"] === null ? null : str(r["thread_id"]),
244
+ tenant: str(r["tenant"]) || "internal",
244
245
  meta: asRecord(safeParse(str(r["meta"]))),
245
246
  createdAt: str(r["created_at"]),
246
247
  updatedAt: str(r["updated_at"])
@@ -280,6 +281,8 @@ var pruneImpl = (db, retentionDays, nowIso) => {
280
281
  try {
281
282
  db.prepare("DELETE FROM workflow_steps WHERE workflow_id=?").run(r.id);
282
283
  db.prepare("DELETE FROM relays WHERE workflow_id=?").run(r.id);
284
+ db.prepare("DELETE FROM prompts WHERE run_id IN (SELECT id FROM runs WHERE workflow_id=?)").run(r.id);
285
+ db.prepare("DELETE FROM runs WHERE workflow_id=?").run(r.id);
283
286
  db.prepare("DELETE FROM workflows WHERE id=?").run(r.id);
284
287
  db.exec("COMMIT");
285
288
  } catch (e) {
@@ -305,7 +308,11 @@ var createLedgerStore = (dbPath, deps = {}) => {
305
308
  if (!wfCols.includes("thread_id")) {
306
309
  db.exec("ALTER TABLE workflows ADD COLUMN thread_id TEXT");
307
310
  }
311
+ if (!wfCols.includes("tenant")) {
312
+ db.exec("ALTER TABLE workflows ADD COLUMN tenant TEXT NOT NULL DEFAULT 'internal'");
313
+ }
308
314
  db.exec("CREATE INDEX IF NOT EXISTS idx_workflows_thread ON workflows(thread_id)");
315
+ db.exec("CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON workflows(tenant, created_at)");
309
316
  const stepCols = db.prepare("PRAGMA table_info(workflow_steps)").all().map((c) => c.name);
310
317
  if (!stepCols.includes("started_at")) db.exec("ALTER TABLE workflow_steps ADD COLUMN started_at TEXT");
311
318
  if (!stepCols.includes("completed_at")) db.exec("ALTER TABLE workflow_steps ADD COLUMN completed_at TEXT");
@@ -327,7 +334,7 @@ var createLedgerStore = (dbPath, deps = {}) => {
327
334
  const activeRunOf = (session) => {
328
335
  const r = db.prepare(
329
336
  `SELECT r.id AS id, r.workflow_id AS workflow_id, r.session AS session,
330
- r.status AS status, r.created_at AS created_at FROM runs r
337
+ r.created_at AS created_at FROM runs r
331
338
  JOIN workflows w ON w.id = r.workflow_id
332
339
  WHERE r.session=? AND r.status IN ('running','idle') AND w.status='running'
333
340
  ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`
@@ -347,19 +354,36 @@ var createLedgerStore = (dbPath, deps = {}) => {
347
354
  const id = ids.newId("wf");
348
355
  const ts = now();
349
356
  db.prepare(
350
- `INSERT INTO workflows (id, parent_id, kind, title, status, owner, meta, created_at, updated_at)
351
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
352
- ).run(id, w.parentId ?? null, w.kind, w.title, w.status ?? "running", w.owner ?? null, JSON.stringify(w.meta ?? {}), ts, ts);
357
+ `INSERT INTO workflows (id, parent_id, kind, title, status, owner, tenant, meta, created_at, updated_at)
358
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
359
+ ).run(id, w.parentId ?? null, w.kind, w.title, w.status ?? "running", w.owner ?? null, w.tenant || "internal", JSON.stringify(w.meta ?? {}), ts, ts);
353
360
  return getWorkflowById(id);
354
361
  },
355
362
  getWorkflow(id) {
356
363
  return getWorkflowById(id);
357
364
  },
358
- listWorkflows(limit = 100, status) {
365
+ listWorkflows(limit = 100, status, tenant) {
366
+ const where = [];
367
+ const params = [];
359
368
  if (status !== void 0 && status !== "") {
360
- return db.prepare("SELECT * FROM workflows WHERE status=? ORDER BY created_at DESC, rowid DESC LIMIT ?").all(status, limit).map(rowToWorkflow);
369
+ where.push("status=?");
370
+ params.push(status);
371
+ }
372
+ if (tenant !== void 0 && tenant !== "") {
373
+ where.push("tenant=?");
374
+ params.push(tenant);
361
375
  }
362
- return db.prepare("SELECT * FROM workflows ORDER BY created_at DESC, rowid DESC LIMIT ?").all(limit).map(rowToWorkflow);
376
+ const sql = where.length > 0 ? `SELECT * FROM workflows WHERE ${where.join(" AND ")} ORDER BY created_at DESC, rowid DESC LIMIT ?` : "SELECT * FROM workflows ORDER BY created_at DESC, rowid DESC LIMIT ?";
377
+ return db.prepare(sql).all(...params, limit).map(rowToWorkflow);
378
+ },
379
+ setWorkflowTenant(id, tenant) {
380
+ const next = tenant === "" ? "internal" : tenant;
381
+ const cur = getWorkflowById(id);
382
+ if (cur === null || cur.tenant === next) return;
383
+ db.prepare("UPDATE workflows SET tenant=?, updated_at=? WHERE id=?").run(next, now(), id);
384
+ db.prepare(
385
+ "INSERT INTO events (entity_type, entity_id, type, payload, ts) VALUES ('workflow', ?, 'tenant_changed', ?, ?)"
386
+ ).run(id, JSON.stringify({ from: cur.tenant, to: next }), now());
363
387
  },
364
388
  setWorkflowSession(id, session) {
365
389
  db.prepare("UPDATE workflows SET session=?, updated_at=? WHERE id=?").run(session, now(), id);
@@ -521,7 +545,18 @@ var createLedgerStore = (dbPath, deps = {}) => {
521
545
  ).run(now(), now(), id);
522
546
  return Number(res.changes) === 1;
523
547
  },
524
- listSteps(status, limit = 100) {
548
+ listSteps(status, limit = 100, tenant) {
549
+ if (tenant !== void 0 && tenant !== "") {
550
+ return db.prepare(
551
+ `SELECT st.id AS id, st.workflow_id AS workflow_id, st.seq AS seq, st.kind AS kind,
552
+ st.title AS title, st.status AS status,
553
+ COALESCE(st.input,'') AS input, COALESCE(st.output,'') AS output,
554
+ COALESCE(st.summary,'') AS summary, st.updated_at AS updated_at,
555
+ st.started_at AS started_at, st.completed_at AS completed_at
556
+ FROM workflow_steps st JOIN workflows w ON w.id = st.workflow_id
557
+ WHERE st.status=? AND w.tenant=? ORDER BY st.updated_at DESC LIMIT ?`
558
+ ).all(status, tenant, limit).map(rowToStep);
559
+ }
525
560
  return db.prepare(
526
561
  `SELECT ${STEP_COLS} FROM workflow_steps WHERE status=? ORDER BY updated_at DESC LIMIT ?`
527
562
  ).all(status, limit).map(rowToStep);
@@ -551,8 +586,9 @@ var createLedgerStore = (dbPath, deps = {}) => {
551
586
  sessionHasOpenWork(session, excludeWorkflowId) {
552
587
  const anchor = "(session=?1 OR COALESCE(json_extract(meta,'$.assigned_session'),'')=?1 OR id IN (SELECT workflow_id FROM runs WHERE session=?1))";
553
588
  const openFilter = `status IN (${sqlInList(ACTIVE_WORKFLOW_STATUSES)})`;
554
- const sql = excludeWorkflowId !== void 0 && excludeWorkflowId !== "" ? `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor} AND id != ?2` : `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor}`;
555
- const row = excludeWorkflowId !== void 0 && excludeWorkflowId !== "" ? db.prepare(sql).get(session, excludeWorkflowId) : db.prepare(sql).get(session);
589
+ const exclude = excludeWorkflowId !== void 0 && excludeWorkflowId !== "";
590
+ const sql = `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor}${exclude ? " AND id != ?2" : ""}`;
591
+ const row = exclude ? db.prepare(sql).get(session, excludeWorkflowId) : db.prepare(sql).get(session);
556
592
  return Number(row["n"]) > 0;
557
593
  },
558
594
  appendEvent(entityType, entityId, type, payload = {}) {
@@ -593,14 +629,15 @@ var createLedgerStore = (dbPath, deps = {}) => {
593
629
  },
594
630
  createRun(workflowId, session) {
595
631
  const id = ids.newId("run");
632
+ const ts = now();
596
633
  db.prepare(`INSERT INTO runs (id, workflow_id, session, status, created_at)
597
- VALUES (?, ?, ?, 'running', ?)`).run(id, workflowId, session, now());
634
+ VALUES (?, ?, ?, 'running', ?)`).run(id, workflowId, session, ts);
598
635
  return {
599
636
  id,
600
637
  workflowId,
601
638
  session,
602
639
  status: "running",
603
- createdAt: now()
640
+ createdAt: ts
604
641
  };
605
642
  },
606
643
  activeRunForSession: (session) => activeRunOf(session),
@@ -623,17 +660,20 @@ var createLedgerStore = (dbPath, deps = {}) => {
623
660
  ).get(dedupPattern(key));
624
661
  return r !== void 0;
625
662
  },
626
- /** 自回环守卫查询(2026-09-20 断点十三):message_id 是否命中本引擎已
627
- * 发送记录——completed send 步的 output.message_id(email_sent 事件同源
628
- * 落账)。进件层据此丢弃投递副本回灌(引擎给轮询邮箱自己发信的 echo)。
629
- * LIKE 预筛 + 精确比对:JSON 编码取实际存储字节,%/_/\ 照 jsonFieldPattern
630
- * 成对转义(message-id 几乎必含 `_`)。 */
663
+ /** 全量出站邮件登记(2026-09-20 幽灵单缺口):send 步之外,通知邮件
664
+ * (emailNotifier)等所有出站信的 message_id 也落 email_sent 事件——
665
+ * 发给轮询邮箱自身的告警信不再被当新来信建单([juli] 待审批 幽灵单)。 */
631
666
  recordSentMessageId(messageId, meta) {
632
667
  if (messageId === "") return;
633
668
  db.prepare(
634
669
  `INSERT INTO events (entity_type, entity_id, type, payload, ts) VALUES ('email', ?, 'email_sent', ?, ?)`
635
670
  ).run(messageId, JSON.stringify(meta ?? {}), now());
636
671
  },
672
+ /** 自回环守卫查询(2026-09-20 断点十三):message_id 是否命中本引擎已
673
+ * 发送记录——completed send 步的 output.message_id(email_sent 事件同源
674
+ * 落账)。进件层据此丢弃投递副本回灌(引擎给轮询邮箱自己发信的 echo)。
675
+ * LIKE 预筛 + 精确比对:JSON 编码取实际存储字节,%/_/\ 照 jsonFieldPattern
676
+ * 成对转义(message-id 几乎必含 `_`)。 */
637
677
  sentMessageIdExists(messageId) {
638
678
  if (messageId === "") return false;
639
679
  const registered = db.prepare(
@@ -652,9 +692,10 @@ var createLedgerStore = (dbPath, deps = {}) => {
652
692
  },
653
693
  createPrompt(runId, content, source) {
654
694
  const id = ids.newId("pr");
695
+ const ts = now();
655
696
  db.prepare(`INSERT INTO prompts (id, run_id, content, source, status, created_at)
656
- VALUES (?, ?, ?, ?, 'submitted', ?)`).run(id, runId, content, source, now());
657
- return { id, runId, content, status: "submitted", createdAt: now() };
697
+ VALUES (?, ?, ?, ?, 'submitted', ?)`).run(id, runId, content, source, ts);
698
+ return { id, runId, content, status: "submitted", createdAt: ts };
658
699
  },
659
700
  prune(retentionDays) {
660
701
  return pruneImpl(db, retentionDays, now());
@@ -698,9 +739,9 @@ var createLedgerStore = (dbPath, deps = {}) => {
698
739
  if (arr === void 0) members.set(root, [w.id]);
699
740
  else arr.push(w.id);
700
741
  }
701
- const views = [...members.entries()].map(([taskId, ids2]) => {
742
+ const views = [...members.entries()].map(([taskId, memberIds]) => {
702
743
  const card = byId.get(taskId);
703
- return { taskId, title: card.title, status: card.status, workflowIds: ids2 };
744
+ return { taskId, title: card.title, status: card.status, workflowIds: memberIds };
704
745
  }).sort((a, b) => {
705
746
  const ca = byId.get(a.taskId)?.createdAt ?? "";
706
747
  const cb = byId.get(b.taskId)?.createdAt ?? "";
@@ -1043,9 +1084,9 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1043
1084
  outputTokens: n(r["o"])
1044
1085
  }));
1045
1086
  return {
1046
- totalCalls: n(total?.["c"]),
1047
- totalInputTokens: n(total?.["i"]),
1048
- totalOutputTokens: n(total?.["o"]),
1087
+ totalCalls: n(total["c"]),
1088
+ totalInputTokens: n(total["i"]),
1089
+ totalOutputTokens: n(total["o"]),
1049
1090
  byModel
1050
1091
  };
1051
1092
  },
@@ -1088,7 +1129,6 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1088
1129
  return windowUsageRows(db, tmuxSession, windows);
1089
1130
  },
1090
1131
  sessionWindowsPriced(sessions, windows) {
1091
- if (sessions.length === 0 || windows.length === 0) return priceUsage([]);
1092
1132
  return priceUsage(sessions.flatMap((s) => windowUsageRows(db, s, windows)));
1093
1133
  },
1094
1134
  byLocalDay(days = 7) {
@@ -1122,10 +1162,17 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1122
1162
  return mapped > 0 && total > 0 && filled * 2 < total;
1123
1163
  },
1124
1164
  resetClaudeIngest() {
1125
- db.exec("DELETE FROM prompt_usage WHERE source='claude_code'");
1126
- db.exec("DELETE FROM claude_ingest_offset");
1127
- db.prepare(`INSERT INTO usage_meta (key, value) VALUES ('last_claude_replay_at', ?)
1128
- ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(now().toISOString());
1165
+ db.exec("BEGIN");
1166
+ try {
1167
+ db.exec("DELETE FROM prompt_usage WHERE source='claude_code'");
1168
+ db.exec("DELETE FROM claude_ingest_offset");
1169
+ db.prepare(`INSERT INTO usage_meta (key, value) VALUES ('last_claude_replay_at', ?)
1170
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(now().toISOString());
1171
+ db.exec("COMMIT");
1172
+ } catch (e) {
1173
+ db.exec("ROLLBACK");
1174
+ throw e;
1175
+ }
1129
1176
  },
1130
1177
  recent(limit = 100) {
1131
1178
  return db.prepare("SELECT * FROM prompt_usage ORDER BY id DESC LIMIT ?").all(limit).map((r) => ({
@@ -1171,7 +1218,7 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1171
1218
  }
1172
1219
  for (const f of files) {
1173
1220
  const stem = f.replace(/\.jsonl$/, "");
1174
- count += ingestFile(db, stem, join(dir, f));
1221
+ count += ingestFile(db, stem, join(dir, f), now);
1175
1222
  }
1176
1223
  }
1177
1224
  } catch {
@@ -1185,7 +1232,7 @@ var tmuxOf = (db, claudeId) => {
1185
1232
  const r = db.prepare("SELECT tmux_session FROM claude_sessions WHERE claude_session_id=?").get(claudeId);
1186
1233
  return r?.["tmux_session"] ?? "";
1187
1234
  };
1188
- var ingestFile = (db, claudeId, transcriptPath) => {
1235
+ var ingestFile = (db, claudeId, transcriptPath, now) => {
1189
1236
  let fd;
1190
1237
  try {
1191
1238
  fd = openSync(transcriptPath, "r");
@@ -1215,7 +1262,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1215
1262
  const sess = tmuxOf(db, claudeId);
1216
1263
  for (const line of complete.split("\n")) {
1217
1264
  if (line === "") continue;
1218
- const rec = parseAssistantLine(line);
1265
+ const rec = parseAssistantLine(line, now);
1219
1266
  if (!rec) continue;
1220
1267
  db.prepare(`INSERT INTO prompt_usage (source, note, model, input_tokens, output_tokens, cached_tokens, duration_ms, error, created_at, tmux_session)
1221
1268
  VALUES ('claude_code', ?, ?, ?, ?, ?, 0, '', ?, ?)`).run(
@@ -1231,7 +1278,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1231
1278
  }
1232
1279
  db.prepare(`INSERT INTO claude_ingest_offset (claude_session_id, byte_offset, last_ingested_at)
1233
1280
  VALUES (?, ?, ?)
1234
- ON CONFLICT(claude_session_id) DO UPDATE SET byte_offset=excluded.byte_offset, last_ingested_at=excluded.last_ingested_at`).run(claudeId, newOffset, (/* @__PURE__ */ new Date()).toISOString());
1281
+ ON CONFLICT(claude_session_id) DO UPDATE SET byte_offset=excluded.byte_offset, last_ingested_at=excluded.last_ingested_at`).run(claudeId, newOffset, now().toISOString());
1235
1282
  db.exec("COMMIT");
1236
1283
  } catch (e) {
1237
1284
  db.exec("ROLLBACK");
@@ -1242,7 +1289,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1242
1289
  closeSync(fd);
1243
1290
  }
1244
1291
  };
1245
- var parseAssistantLine = (line) => {
1292
+ var parseAssistantLine = (line, now) => {
1246
1293
  try {
1247
1294
  const v = JSON.parse(line);
1248
1295
  if (v.type !== "assistant") return null;
@@ -1257,7 +1304,7 @@ var parseAssistantLine = (line) => {
1257
1304
  inputTokens: input,
1258
1305
  outputTokens: output,
1259
1306
  cacheRead,
1260
- timestamp: typeof v.timestamp === "string" ? v.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
1307
+ timestamp: typeof v.timestamp === "string" ? v.timestamp : now().toISOString(),
1261
1308
  callContext: v.uuid ?? ""
1262
1309
  };
1263
1310
  } catch {
@@ -105,7 +105,7 @@ var parseCaseFile = (raw, now) => {
105
105
  decisions: pick(arr("decisions"), (d) => "action" in d),
106
106
  references: arr("references").filter((x) => typeof x === "string"),
107
107
  artifacts: pick(arr("artifacts"), (a) => "id" in a),
108
- updatedAt: str2("updated_at") || base.updatedAt
108
+ updatedAt: str2("updatedAt") || str2("updated_at") || base.updatedAt
109
109
  };
110
110
  };
111
111
  var serializeCaseFile = (cf) => JSON.stringify(cf);
@@ -198,7 +198,8 @@ var rowToWorkflow = (r) => ({
198
198
  session: str(r["session"]),
199
199
  project: str(r["project"]),
200
200
  path: str(r["path"]),
201
- threadId: r["thread_id"] === null || r["thread_id"] === void 0 ? null : str(r["thread_id"]),
201
+ threadId: r["thread_id"] === null ? null : str(r["thread_id"]),
202
+ tenant: str(r["tenant"]) || "internal",
202
203
  meta: asRecord(safeParse(str(r["meta"]))),
203
204
  createdAt: str(r["created_at"]),
204
205
  updatedAt: str(r["updated_at"])
@@ -238,6 +239,8 @@ var pruneImpl = (db, retentionDays, nowIso) => {
238
239
  try {
239
240
  db.prepare("DELETE FROM workflow_steps WHERE workflow_id=?").run(r.id);
240
241
  db.prepare("DELETE FROM relays WHERE workflow_id=?").run(r.id);
242
+ db.prepare("DELETE FROM prompts WHERE run_id IN (SELECT id FROM runs WHERE workflow_id=?)").run(r.id);
243
+ db.prepare("DELETE FROM runs WHERE workflow_id=?").run(r.id);
241
244
  db.prepare("DELETE FROM workflows WHERE id=?").run(r.id);
242
245
  db.exec("COMMIT");
243
246
  } catch (e) {
@@ -263,7 +266,11 @@ var createLedgerStore = (dbPath, deps = {}) => {
263
266
  if (!wfCols.includes("thread_id")) {
264
267
  db.exec("ALTER TABLE workflows ADD COLUMN thread_id TEXT");
265
268
  }
269
+ if (!wfCols.includes("tenant")) {
270
+ db.exec("ALTER TABLE workflows ADD COLUMN tenant TEXT NOT NULL DEFAULT 'internal'");
271
+ }
266
272
  db.exec("CREATE INDEX IF NOT EXISTS idx_workflows_thread ON workflows(thread_id)");
273
+ db.exec("CREATE INDEX IF NOT EXISTS idx_workflows_tenant ON workflows(tenant, created_at)");
267
274
  const stepCols = db.prepare("PRAGMA table_info(workflow_steps)").all().map((c) => c.name);
268
275
  if (!stepCols.includes("started_at")) db.exec("ALTER TABLE workflow_steps ADD COLUMN started_at TEXT");
269
276
  if (!stepCols.includes("completed_at")) db.exec("ALTER TABLE workflow_steps ADD COLUMN completed_at TEXT");
@@ -285,7 +292,7 @@ var createLedgerStore = (dbPath, deps = {}) => {
285
292
  const activeRunOf = (session) => {
286
293
  const r = db.prepare(
287
294
  `SELECT r.id AS id, r.workflow_id AS workflow_id, r.session AS session,
288
- r.status AS status, r.created_at AS created_at FROM runs r
295
+ r.created_at AS created_at FROM runs r
289
296
  JOIN workflows w ON w.id = r.workflow_id
290
297
  WHERE r.session=? AND r.status IN ('running','idle') AND w.status='running'
291
298
  ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`
@@ -305,19 +312,36 @@ var createLedgerStore = (dbPath, deps = {}) => {
305
312
  const id = ids.newId("wf");
306
313
  const ts = now();
307
314
  db.prepare(
308
- `INSERT INTO workflows (id, parent_id, kind, title, status, owner, meta, created_at, updated_at)
309
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
310
- ).run(id, w.parentId ?? null, w.kind, w.title, w.status ?? "running", w.owner ?? null, JSON.stringify(w.meta ?? {}), ts, ts);
315
+ `INSERT INTO workflows (id, parent_id, kind, title, status, owner, tenant, meta, created_at, updated_at)
316
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
317
+ ).run(id, w.parentId ?? null, w.kind, w.title, w.status ?? "running", w.owner ?? null, w.tenant || "internal", JSON.stringify(w.meta ?? {}), ts, ts);
311
318
  return getWorkflowById(id);
312
319
  },
313
320
  getWorkflow(id) {
314
321
  return getWorkflowById(id);
315
322
  },
316
- listWorkflows(limit = 100, status) {
323
+ listWorkflows(limit = 100, status, tenant) {
324
+ const where = [];
325
+ const params = [];
317
326
  if (status !== void 0 && status !== "") {
318
- return db.prepare("SELECT * FROM workflows WHERE status=? ORDER BY created_at DESC, rowid DESC LIMIT ?").all(status, limit).map(rowToWorkflow);
327
+ where.push("status=?");
328
+ params.push(status);
329
+ }
330
+ if (tenant !== void 0 && tenant !== "") {
331
+ where.push("tenant=?");
332
+ params.push(tenant);
319
333
  }
320
- return db.prepare("SELECT * FROM workflows ORDER BY created_at DESC, rowid DESC LIMIT ?").all(limit).map(rowToWorkflow);
334
+ const sql = where.length > 0 ? `SELECT * FROM workflows WHERE ${where.join(" AND ")} ORDER BY created_at DESC, rowid DESC LIMIT ?` : "SELECT * FROM workflows ORDER BY created_at DESC, rowid DESC LIMIT ?";
335
+ return db.prepare(sql).all(...params, limit).map(rowToWorkflow);
336
+ },
337
+ setWorkflowTenant(id, tenant) {
338
+ const next = tenant === "" ? "internal" : tenant;
339
+ const cur = getWorkflowById(id);
340
+ if (cur === null || cur.tenant === next) return;
341
+ db.prepare("UPDATE workflows SET tenant=?, updated_at=? WHERE id=?").run(next, now(), id);
342
+ db.prepare(
343
+ "INSERT INTO events (entity_type, entity_id, type, payload, ts) VALUES ('workflow', ?, 'tenant_changed', ?, ?)"
344
+ ).run(id, JSON.stringify({ from: cur.tenant, to: next }), now());
321
345
  },
322
346
  setWorkflowSession(id, session) {
323
347
  db.prepare("UPDATE workflows SET session=?, updated_at=? WHERE id=?").run(session, now(), id);
@@ -479,7 +503,18 @@ var createLedgerStore = (dbPath, deps = {}) => {
479
503
  ).run(now(), now(), id);
480
504
  return Number(res.changes) === 1;
481
505
  },
482
- listSteps(status, limit = 100) {
506
+ listSteps(status, limit = 100, tenant) {
507
+ if (tenant !== void 0 && tenant !== "") {
508
+ return db.prepare(
509
+ `SELECT st.id AS id, st.workflow_id AS workflow_id, st.seq AS seq, st.kind AS kind,
510
+ st.title AS title, st.status AS status,
511
+ COALESCE(st.input,'') AS input, COALESCE(st.output,'') AS output,
512
+ COALESCE(st.summary,'') AS summary, st.updated_at AS updated_at,
513
+ st.started_at AS started_at, st.completed_at AS completed_at
514
+ FROM workflow_steps st JOIN workflows w ON w.id = st.workflow_id
515
+ WHERE st.status=? AND w.tenant=? ORDER BY st.updated_at DESC LIMIT ?`
516
+ ).all(status, tenant, limit).map(rowToStep);
517
+ }
483
518
  return db.prepare(
484
519
  `SELECT ${STEP_COLS} FROM workflow_steps WHERE status=? ORDER BY updated_at DESC LIMIT ?`
485
520
  ).all(status, limit).map(rowToStep);
@@ -509,8 +544,9 @@ var createLedgerStore = (dbPath, deps = {}) => {
509
544
  sessionHasOpenWork(session, excludeWorkflowId) {
510
545
  const anchor = "(session=?1 OR COALESCE(json_extract(meta,'$.assigned_session'),'')=?1 OR id IN (SELECT workflow_id FROM runs WHERE session=?1))";
511
546
  const openFilter = `status IN (${sqlInList(ACTIVE_WORKFLOW_STATUSES)})`;
512
- const sql = excludeWorkflowId !== void 0 && excludeWorkflowId !== "" ? `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor} AND id != ?2` : `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor}`;
513
- const row = excludeWorkflowId !== void 0 && excludeWorkflowId !== "" ? db.prepare(sql).get(session, excludeWorkflowId) : db.prepare(sql).get(session);
547
+ const exclude = excludeWorkflowId !== void 0 && excludeWorkflowId !== "";
548
+ const sql = `SELECT COUNT(*) AS n FROM workflows WHERE ${openFilter} AND ${anchor}${exclude ? " AND id != ?2" : ""}`;
549
+ const row = exclude ? db.prepare(sql).get(session, excludeWorkflowId) : db.prepare(sql).get(session);
514
550
  return Number(row["n"]) > 0;
515
551
  },
516
552
  appendEvent(entityType, entityId, type, payload = {}) {
@@ -551,14 +587,15 @@ var createLedgerStore = (dbPath, deps = {}) => {
551
587
  },
552
588
  createRun(workflowId, session) {
553
589
  const id = ids.newId("run");
590
+ const ts = now();
554
591
  db.prepare(`INSERT INTO runs (id, workflow_id, session, status, created_at)
555
- VALUES (?, ?, ?, 'running', ?)`).run(id, workflowId, session, now());
592
+ VALUES (?, ?, ?, 'running', ?)`).run(id, workflowId, session, ts);
556
593
  return {
557
594
  id,
558
595
  workflowId,
559
596
  session,
560
597
  status: "running",
561
- createdAt: now()
598
+ createdAt: ts
562
599
  };
563
600
  },
564
601
  activeRunForSession: (session) => activeRunOf(session),
@@ -581,17 +618,20 @@ var createLedgerStore = (dbPath, deps = {}) => {
581
618
  ).get(dedupPattern(key));
582
619
  return r !== void 0;
583
620
  },
584
- /** 自回环守卫查询(2026-09-20 断点十三):message_id 是否命中本引擎已
585
- * 发送记录——completed send 步的 output.message_id(email_sent 事件同源
586
- * 落账)。进件层据此丢弃投递副本回灌(引擎给轮询邮箱自己发信的 echo)。
587
- * LIKE 预筛 + 精确比对:JSON 编码取实际存储字节,%/_/\ 照 jsonFieldPattern
588
- * 成对转义(message-id 几乎必含 `_`)。 */
621
+ /** 全量出站邮件登记(2026-09-20 幽灵单缺口):send 步之外,通知邮件
622
+ * (emailNotifier)等所有出站信的 message_id 也落 email_sent 事件——
623
+ * 发给轮询邮箱自身的告警信不再被当新来信建单([juli] 待审批 幽灵单)。 */
589
624
  recordSentMessageId(messageId, meta) {
590
625
  if (messageId === "") return;
591
626
  db.prepare(
592
627
  `INSERT INTO events (entity_type, entity_id, type, payload, ts) VALUES ('email', ?, 'email_sent', ?, ?)`
593
628
  ).run(messageId, JSON.stringify(meta ?? {}), now());
594
629
  },
630
+ /** 自回环守卫查询(2026-09-20 断点十三):message_id 是否命中本引擎已
631
+ * 发送记录——completed send 步的 output.message_id(email_sent 事件同源
632
+ * 落账)。进件层据此丢弃投递副本回灌(引擎给轮询邮箱自己发信的 echo)。
633
+ * LIKE 预筛 + 精确比对:JSON 编码取实际存储字节,%/_/\ 照 jsonFieldPattern
634
+ * 成对转义(message-id 几乎必含 `_`)。 */
595
635
  sentMessageIdExists(messageId) {
596
636
  if (messageId === "") return false;
597
637
  const registered = db.prepare(
@@ -610,9 +650,10 @@ var createLedgerStore = (dbPath, deps = {}) => {
610
650
  },
611
651
  createPrompt(runId, content, source) {
612
652
  const id = ids.newId("pr");
653
+ const ts = now();
613
654
  db.prepare(`INSERT INTO prompts (id, run_id, content, source, status, created_at)
614
- VALUES (?, ?, ?, ?, 'submitted', ?)`).run(id, runId, content, source, now());
615
- return { id, runId, content, status: "submitted", createdAt: now() };
655
+ VALUES (?, ?, ?, ?, 'submitted', ?)`).run(id, runId, content, source, ts);
656
+ return { id, runId, content, status: "submitted", createdAt: ts };
616
657
  },
617
658
  prune(retentionDays) {
618
659
  return pruneImpl(db, retentionDays, now());
@@ -656,9 +697,9 @@ var createLedgerStore = (dbPath, deps = {}) => {
656
697
  if (arr === void 0) members.set(root, [w.id]);
657
698
  else arr.push(w.id);
658
699
  }
659
- const views = [...members.entries()].map(([taskId, ids2]) => {
700
+ const views = [...members.entries()].map(([taskId, memberIds]) => {
660
701
  const card = byId.get(taskId);
661
- return { taskId, title: card.title, status: card.status, workflowIds: ids2 };
702
+ return { taskId, title: card.title, status: card.status, workflowIds: memberIds };
662
703
  }).sort((a, b) => {
663
704
  const ca = byId.get(a.taskId)?.createdAt ?? "";
664
705
  const cb = byId.get(b.taskId)?.createdAt ?? "";
@@ -1001,9 +1042,9 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1001
1042
  outputTokens: n(r["o"])
1002
1043
  }));
1003
1044
  return {
1004
- totalCalls: n(total?.["c"]),
1005
- totalInputTokens: n(total?.["i"]),
1006
- totalOutputTokens: n(total?.["o"]),
1045
+ totalCalls: n(total["c"]),
1046
+ totalInputTokens: n(total["i"]),
1047
+ totalOutputTokens: n(total["o"]),
1007
1048
  byModel
1008
1049
  };
1009
1050
  },
@@ -1046,7 +1087,6 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1046
1087
  return windowUsageRows(db, tmuxSession, windows);
1047
1088
  },
1048
1089
  sessionWindowsPriced(sessions, windows) {
1049
- if (sessions.length === 0 || windows.length === 0) return priceUsage([]);
1050
1090
  return priceUsage(sessions.flatMap((s) => windowUsageRows(db, s, windows)));
1051
1091
  },
1052
1092
  byLocalDay(days = 7) {
@@ -1080,10 +1120,17 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1080
1120
  return mapped > 0 && total > 0 && filled * 2 < total;
1081
1121
  },
1082
1122
  resetClaudeIngest() {
1083
- db.exec("DELETE FROM prompt_usage WHERE source='claude_code'");
1084
- db.exec("DELETE FROM claude_ingest_offset");
1085
- db.prepare(`INSERT INTO usage_meta (key, value) VALUES ('last_claude_replay_at', ?)
1086
- ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(now().toISOString());
1123
+ db.exec("BEGIN");
1124
+ try {
1125
+ db.exec("DELETE FROM prompt_usage WHERE source='claude_code'");
1126
+ db.exec("DELETE FROM claude_ingest_offset");
1127
+ db.prepare(`INSERT INTO usage_meta (key, value) VALUES ('last_claude_replay_at', ?)
1128
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(now().toISOString());
1129
+ db.exec("COMMIT");
1130
+ } catch (e) {
1131
+ db.exec("ROLLBACK");
1132
+ throw e;
1133
+ }
1087
1134
  },
1088
1135
  recent(limit = 100) {
1089
1136
  return db.prepare("SELECT * FROM prompt_usage ORDER BY id DESC LIMIT ?").all(limit).map((r) => ({
@@ -1129,7 +1176,7 @@ var createUsageStore = (dbPath, now = () => /* @__PURE__ */ new Date()) => {
1129
1176
  }
1130
1177
  for (const f of files) {
1131
1178
  const stem = f.replace(/\.jsonl$/, "");
1132
- count += ingestFile(db, stem, join(dir2, f));
1179
+ count += ingestFile(db, stem, join(dir2, f), now);
1133
1180
  }
1134
1181
  }
1135
1182
  } catch {
@@ -1143,7 +1190,7 @@ var tmuxOf = (db, claudeId) => {
1143
1190
  const r = db.prepare("SELECT tmux_session FROM claude_sessions WHERE claude_session_id=?").get(claudeId);
1144
1191
  return r?.["tmux_session"] ?? "";
1145
1192
  };
1146
- var ingestFile = (db, claudeId, transcriptPath) => {
1193
+ var ingestFile = (db, claudeId, transcriptPath, now) => {
1147
1194
  let fd;
1148
1195
  try {
1149
1196
  fd = openSync(transcriptPath, "r");
@@ -1173,7 +1220,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1173
1220
  const sess = tmuxOf(db, claudeId);
1174
1221
  for (const line of complete.split("\n")) {
1175
1222
  if (line === "") continue;
1176
- const rec = parseAssistantLine(line);
1223
+ const rec = parseAssistantLine(line, now);
1177
1224
  if (!rec) continue;
1178
1225
  db.prepare(`INSERT INTO prompt_usage (source, note, model, input_tokens, output_tokens, cached_tokens, duration_ms, error, created_at, tmux_session)
1179
1226
  VALUES ('claude_code', ?, ?, ?, ?, ?, 0, '', ?, ?)`).run(
@@ -1189,7 +1236,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1189
1236
  }
1190
1237
  db.prepare(`INSERT INTO claude_ingest_offset (claude_session_id, byte_offset, last_ingested_at)
1191
1238
  VALUES (?, ?, ?)
1192
- ON CONFLICT(claude_session_id) DO UPDATE SET byte_offset=excluded.byte_offset, last_ingested_at=excluded.last_ingested_at`).run(claudeId, newOffset, (/* @__PURE__ */ new Date()).toISOString());
1239
+ ON CONFLICT(claude_session_id) DO UPDATE SET byte_offset=excluded.byte_offset, last_ingested_at=excluded.last_ingested_at`).run(claudeId, newOffset, now().toISOString());
1193
1240
  db.exec("COMMIT");
1194
1241
  } catch (e) {
1195
1242
  db.exec("ROLLBACK");
@@ -1200,7 +1247,7 @@ var ingestFile = (db, claudeId, transcriptPath) => {
1200
1247
  closeSync(fd);
1201
1248
  }
1202
1249
  };
1203
- var parseAssistantLine = (line) => {
1250
+ var parseAssistantLine = (line, now) => {
1204
1251
  try {
1205
1252
  const v = JSON.parse(line);
1206
1253
  if (v.type !== "assistant") return null;
@@ -1215,7 +1262,7 @@ var parseAssistantLine = (line) => {
1215
1262
  inputTokens: input,
1216
1263
  outputTokens: output,
1217
1264
  cacheRead,
1218
- timestamp: typeof v.timestamp === "string" ? v.timestamp : (/* @__PURE__ */ new Date()).toISOString(),
1265
+ timestamp: typeof v.timestamp === "string" ? v.timestamp : now().toISOString(),
1219
1266
  callContext: v.uuid ?? ""
1220
1267
  };
1221
1268
  } catch {
@@ -12,11 +12,13 @@ export interface LifecycleStore {
12
12
  parentId?: string;
13
13
  status?: WorkflowStatus;
14
14
  owner?: string;
15
+ tenant?: string;
15
16
  }): Workflow;
16
17
  getWorkflow(id: string): Workflow | null;
17
- listWorkflows(limit?: number, status?: string): readonly Workflow[];
18
+ listWorkflows(limit?: number, status?: string, tenant?: string): readonly Workflow[];
18
19
  setWorkflowStatus(id: string, status: WorkflowStatus): void;
19
20
  setWorkflowSession(id: string, session: string): void;
21
+ setWorkflowTenant(id: string, tenant: string): void;
20
22
  updateWorkflowMeta(id: string, patch: JsonRecord): JsonRecord;
21
23
  setWorkflowRouting(id: string, session: string, project: string): void;
22
24
  assignThread(workflowId: string, threadId: string): void;
@@ -64,7 +66,7 @@ export interface LifecycleStore {
64
66
  setStepOutput(id: string, patch: JsonRecord): void;
65
67
  appendStepOutputItem(id: string, key: string, entry: JsonValue, cap: number): void;
66
68
  claimRunningStep(id: string): boolean;
67
- listSteps(status: StepStatus, limit?: number): readonly Step[];
69
+ listSteps(status: StepStatus, limit?: number, tenant?: string): readonly Step[];
68
70
  runningAgentStepForSession(session: string): Step | null;
69
71
  sessionHasOpenWork(session: string, excludeWorkflowId?: string): boolean;
70
72
  appendEvent(entityType: 'workflow' | 'step' | 'judgment' | 'agent', entityId: string, type: string, payload?: JsonRecord): LedgerEvent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juli-os/ledger",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "juli-os L1 layer: the ledger of record for agent work — workflows, steps, case files, and token usage in one zero-dependency SQLite layer.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,11 +27,15 @@
27
27
  "build": "tsc -p tsconfig.build.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/index.js && esbuild bin/juli-ledger.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/juli-ledger.js && chmod +x dist/juli-ledger.js && python3 scripts/postbuild.py",
28
28
  "test": "node --test \"test/*.test.ts\"",
29
29
  "smoke:dist": "node --input-type=module -e \"import('./dist/index.js').then(async m => { const { join } = await import('node:path'); const { tmpdir } = await import('node:os'); const s = m.createLedgerStore(join(tmpdir(), 'peel-dist-' + Date.now() + '.db')); const wf = s.createWorkflow({kind:'case',title:'dist smoke'}); if (!s.getWorkflow(wf.id)) process.exit(1); console.log('DIST SMOKE OK'); })\"",
30
+ "docs:gen": "node scripts/gen-docs.mjs && typedoc",
31
+ "docs:build": "npm run docs:gen && mkdocs build --strict",
30
32
  "prepublishOnly": "npm run build"
31
33
  },
32
34
  "devDependencies": {
33
35
  "@types/node": "^22.0.0",
34
36
  "esbuild": "^0.24.0",
37
+ "typedoc": "^0.28.20",
38
+ "typedoc-plugin-markdown": "^4.14.1",
35
39
  "typescript": "^5.6.0"
36
40
  },
37
41
  "publishConfig": {