@tpsdev-ai/flair 0.52.0 → 0.54.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.
Files changed (99) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1914 -15328
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1654 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +914 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1592 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/fabric-npm-install.js +87 -0
  43. package/dist/federation-verify.js +498 -0
  44. package/dist/fleet-verify.js +144 -21
  45. package/dist/install/clients.js +167 -0
  46. package/dist/lib/auth-resolve.js +76 -1
  47. package/dist/lib/daemon-liveness.js +131 -2
  48. package/dist/lib/doctor-config-path.js +61 -0
  49. package/dist/lib/doctor-federation-driver.js +189 -0
  50. package/dist/lib/doctor-run.js +40 -0
  51. package/dist/lib/entity-vocab-cli.js +3 -3
  52. package/dist/lib/federation-pair-identity.js +47 -0
  53. package/dist/lib/launchd-repair.js +199 -0
  54. package/dist/lib/ops-api-bind.js +115 -0
  55. package/dist/lib/owned-pins.js +219 -0
  56. package/dist/lib/stabilize-mqtt-network.js +123 -0
  57. package/dist/lib/uninstall-purge.js +218 -0
  58. package/dist/rem/restore.js +8 -10
  59. package/dist/resources/AgentReadPosition.js +74 -0
  60. package/dist/resources/Federation.js +8 -2
  61. package/dist/resources/Memory.js +4 -3
  62. package/dist/resources/MemoryBootstrap.js +41 -25
  63. package/dist/resources/MemoryCandidate.js +5 -6
  64. package/dist/resources/OrgEventCatchup.js +126 -47
  65. package/dist/resources/agent-read-position-lib.js +83 -0
  66. package/dist/resources/agent-read-position.js +120 -0
  67. package/dist/resources/embeddings-boot.js +32 -0
  68. package/dist/resources/federation-peer-liveness.js +73 -0
  69. package/dist/resources/health.js +68 -19
  70. package/dist/resources/mcp-tools.js +43 -279
  71. package/dist/resources/memory-visibility.js +3 -3
  72. package/dist/resources/migration-boot.js +59 -18
  73. package/dist/resources/migrations/embedding-stamp.js +20 -1
  74. package/dist/resources/migrations/recheck.js +43 -0
  75. package/dist/resources/migrations/runner.js +6 -1
  76. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  77. package/dist/resources/migrations/visibility-backfill.js +2 -2
  78. package/dist/resources/org-event-catchup-lib.js +47 -0
  79. package/dist/resources/record-owner-guard.js +1 -0
  80. package/dist/stamp-migration-verify.js +163 -0
  81. package/dist/stamp-outstanding.js +144 -0
  82. package/docs/api-reference.md +4 -2
  83. package/docs/deploying-on-fabric.md +11 -10
  84. package/docs/deployment.md +3 -1
  85. package/docs/federation.md +19 -0
  86. package/docs/hosted-on-fabric.md +3 -3
  87. package/docs/quickstart.md +2 -1
  88. package/docs/releasing.md +15 -7
  89. package/docs/spoke-bringup.md +10 -5
  90. package/docs/standalone-local.md +3 -1
  91. package/docs/upgrade.md +25 -6
  92. package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
  93. package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
  94. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
  95. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
  96. package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
  97. package/package.json +9 -4
  98. package/schemas/agent.graphql +15 -0
  99. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
@@ -1,24 +1,94 @@
1
1
  /**
2
- * OrgEventCatchup.ts — Custom resource for filtered event retrieval.
2
+ * OrgEventCatchup.ts — Paginated catch-up against a per-agent watermark.
3
3
  *
4
- * GET /OrgEventCatchup/{participantId}?since=<ISO timestamp>
4
+ * GET /OrgEventCatchup/{participantId}?after=<position>&since=<ISO>&limit=50
5
+ * POST /OrgEventCatchup/{participantId} { position }
5
6
  *
6
7
  * Returns events where:
7
8
  * - targetIds includes participantId OR targetIds is empty/null
8
- * - createdAt >= since
9
- * Sorted by createdAt ascending (oldest first for in-order processing).
10
- * Limit 50 events max.
9
+ * - position > after (after defaults to the durable per-agent watermark)
10
+ * - optional `since` is an extra createdAt floor, never the primary cursor
11
+ *
12
+ * Ordered by position ascending. Page-sized, never silently truncated
13
+ * (`hasMore` + `nextAfter` — the caller pages until drained). Watermark
14
+ * advances on ack (POST), not on read — at-least-once; consumers must be
15
+ * idempotent (flair#931).
11
16
  */
12
17
  import { Resource, databases } from "harper";
13
18
  import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
19
+ import { initialPosition, ORG_EVENT_STREAM, parsePageSize, positionAfterTimestamp, } from "./agent-read-position-lib.js";
20
+ import { advanceReadPosition, defaultReadPositionTable, ensureReadPosition, getReadPosition, } from "./agent-read-position.js";
21
+ import { catchupSeekTimestamp, collectCatchupEvents, pageCatchupEvents, } from "./org-event-catchup-lib.js";
22
+ function queryValue(pathInfo, name) {
23
+ if (typeof pathInfo !== "object" || pathInfo === null)
24
+ return null;
25
+ return pathInfo.conditions?.find((c) => c.attribute === name)?.value ?? null;
26
+ }
27
+ function pathParticipantId(pathInfo, resource) {
28
+ return ((typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
29
+ (typeof pathInfo === "string" ? pathInfo : null) ??
30
+ resource.getId?.() ??
31
+ null);
32
+ }
33
+ function ownerDenied(auth, participantId) {
34
+ if (auth.kind === "anonymous")
35
+ return true;
36
+ if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId)
37
+ return true;
38
+ return false;
39
+ }
40
+ function forbidden() {
41
+ return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
42
+ }
43
+ async function resolveAfter(participantId, pathInfo, ctx) {
44
+ const afterParam = queryValue(pathInfo, "after");
45
+ const since = queryValue(pathInfo, "since");
46
+ if (since) {
47
+ const sinceDate = new Date(since);
48
+ if (isNaN(sinceDate.getTime())) {
49
+ throw new Response(JSON.stringify({ error: "invalid since timestamp" }), { status: 400, headers: { "Content-Type": "application/json" } });
50
+ }
51
+ }
52
+ const table = defaultReadPositionTable();
53
+ const watermark = await getReadPosition(table, participantId, ORG_EVENT_STREAM, ctx);
54
+ if (afterParam != null && afterParam !== "") {
55
+ return { after: afterParam, watermark, since };
56
+ }
57
+ if (watermark) {
58
+ return { after: watermark, watermark, since };
59
+ }
60
+ if (since) {
61
+ // Legacy explicit since, no watermark yet — do not invent a "now" floor
62
+ // that would hide the window the caller asked for.
63
+ return { after: positionAfterTimestamp(since), watermark: null, since };
64
+ }
65
+ const created = await ensureReadPosition(table, participantId, ORG_EVENT_STREAM, initialPosition(), ctx);
66
+ return { after: created, watermark: created, since: null };
67
+ }
68
+ async function searchOrgEvents(seekTs) {
69
+ const table = databases.flair?.OrgEvent;
70
+ if (!table?.search) {
71
+ async function* empty() { }
72
+ return empty();
73
+ }
74
+ const query = seekTs
75
+ ? {
76
+ conditions: [{ attribute: "createdAt", comparator: "greater_than_equal", value: seekTs }],
77
+ }
78
+ : undefined;
79
+ return table.search(query);
80
+ }
14
81
  export class OrgEventCatchup extends Resource {
15
82
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
16
83
  // admin elevation). Any verified agent may catch up; participant scoping is in
17
- // get(). Uses getContext().request — the reliable v5 path (this.request is not
18
- // populated on Harper v5 Resources).
84
+ // get()/post(). Uses getContext().request — the reliable v5 path (this.request
85
+ // is not populated on Harper v5 Resources).
19
86
  async allowRead() {
20
87
  return allowVerified(this.getContext?.());
21
88
  }
89
+ async allowCreate() {
90
+ return allowVerified(this.getContext?.());
91
+ }
22
92
  // HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
23
93
  async get(pathInfo) {
24
94
  // Harper v5 does not populate this.request on Resource subclasses —
@@ -27,55 +97,64 @@ export class OrgEventCatchup extends Resource {
27
97
  // any).request` read was always undefined, so the ownership check below
28
98
  // never ran — fail-open cross-agent read).
29
99
  const auth = await resolveAgentAuth(this.getContext?.());
30
- // Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
31
- const participantId = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
32
- (typeof pathInfo === "string" ? pathInfo : null) ??
33
- this.getId?.() ??
34
- null;
100
+ const ctx = this.getContext?.();
101
+ const participantId = pathParticipantId(pathInfo, this);
35
102
  if (!participantId) {
36
103
  return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
37
104
  }
38
105
  // Auth: internal calls and admins pass unfiltered; a verified agent may only
39
106
  // fetch its own catchup feed; anonymous is denied. allowRead() already
40
107
  // blocks anonymous HTTP, but this handler must fail closed on its own too.
41
- if (auth.kind === "anonymous") {
42
- return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
43
- }
44
- if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId) {
45
- return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
108
+ if (ownerDenied(auth, participantId))
109
+ return forbidden();
110
+ let resolved;
111
+ try {
112
+ resolved = await resolveAfter(participantId, pathInfo, ctx);
46
113
  }
47
- // Harper parses query params into pathInfo.conditions array:
48
- // e.g. ?since=value → conditions: [{attribute:"since", value:"...", comparator:"equals"}]
49
- // pathInfo.id is the URL path segment (participantId).
50
- const since = (typeof pathInfo === "object" && pathInfo !== null
51
- ? pathInfo.conditions?.find((c) => c.attribute === "since")?.value ?? null
52
- : null);
53
- if (!since) {
54
- return new Response(JSON.stringify({ error: "since query parameter required (ISO timestamp)" }), { status: 400, headers: { "Content-Type": "application/json" } });
114
+ catch (err) {
115
+ if (err instanceof Response)
116
+ return err;
117
+ throw err;
55
118
  }
56
- const sinceDate = new Date(since);
57
- if (isNaN(sinceDate.getTime())) {
58
- return new Response(JSON.stringify({ error: "invalid since timestamp" }), { status: 400, headers: { "Content-Type": "application/json" } });
119
+ const pageSize = parsePageSize(queryValue(pathInfo, "limit"));
120
+ const seekTs = catchupSeekTimestamp(resolved.after, resolved.since);
121
+ const ordered = await collectCatchupEvents(await searchOrgEvents(seekTs), {
122
+ participantId,
123
+ after: resolved.after,
124
+ since: resolved.since,
125
+ });
126
+ const paged = pageCatchupEvents(ordered, resolved.after, pageSize);
127
+ return {
128
+ events: paged.page,
129
+ watermark: resolved.watermark,
130
+ after: resolved.after,
131
+ nextAfter: paged.nextAfter,
132
+ hasMore: paged.hasMore,
133
+ pageSize,
134
+ };
135
+ }
136
+ /**
137
+ * Advance-on-ack. Body: `{ position }` (the last delivered event's
138
+ * `position`, or `nextAfter` from a drained page). Monotonic.
139
+ */
140
+ async post(content, pathInfo) {
141
+ const auth = await resolveAgentAuth(this.getContext?.());
142
+ const ctx = this.getContext?.();
143
+ const participantId = pathParticipantId(pathInfo, this) ??
144
+ (content && typeof content === "object" ? content.participantId ?? content.agentId : null) ??
145
+ null;
146
+ if (!participantId) {
147
+ return new Response(JSON.stringify({ error: "participantId required in path: POST /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
59
148
  }
60
- // Query all OrgEvents and filter in-memory
61
- const results = [];
62
- for await (const event of databases.flair.OrgEvent.search()) {
63
- // Filter by createdAt >= since
64
- if (!event.createdAt || event.createdAt < since)
65
- continue;
66
- // Filter by targetIds: includes participantId OR empty/null
67
- const targets = event.targetIds;
68
- const isTargeted = !targets || targets.length === 0 || targets.includes(participantId);
69
- if (!isTargeted)
70
- continue;
71
- // Skip expired events
72
- if (event.expiresAt && new Date(event.expiresAt) < new Date())
73
- continue;
74
- results.push(event);
149
+ if (ownerDenied(auth, participantId))
150
+ return forbidden();
151
+ const position = content && typeof content === "object"
152
+ ? String(content.position ?? content.ackThrough ?? "")
153
+ : "";
154
+ if (!position) {
155
+ return new Response(JSON.stringify({ error: "position required to ack catch-up (advance-on-ack)" }), { status: 400, headers: { "Content-Type": "application/json" } });
75
156
  }
76
- // Sort ascending by createdAt (oldest first)
77
- results.sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || ""));
78
- // Limit to 50
79
- return results.slice(0, 50);
157
+ const result = await advanceReadPosition(defaultReadPositionTable(), participantId, ORG_EVENT_STREAM, position, ctx);
158
+ return { participantId, stream: ORG_EVENT_STREAM, ...result };
80
159
  }
81
160
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * agent-read-position-lib.ts — pure per-agent read-position (watermark) helpers.
3
+ *
4
+ * This is the reusable catch-up floor (flair#931), not OrgEvent-table plumbing.
5
+ * OrgEventCatchup is the first consumer; light-comms (#1583) inherits the same
6
+ * (agentId, stream) → position primitive when the board lands.
7
+ *
8
+ * Position is a monotonic total order, NOT a wall-clock `since`:
9
+ * `${createdAt}\t${id}`
10
+ * ISO-8601 timestamps sort lexicographically; `id` breaks same-ms ties so a
11
+ * sort without a tie-break cannot drop an event. Compare with `<` / `>`.
12
+ *
13
+ * Harper-free so unit tests can pin encoding, paging, and init without a store.
14
+ */
15
+ import { compareKey } from "./sort-comparators.js";
16
+ /** First shipped stream. Additional streams (message-board, …) are additive. */
17
+ export const ORG_EVENT_STREAM = "org-event";
18
+ export const POSITION_SEP = "\t";
19
+ export const DEFAULT_CATCHUP_PAGE_SIZE = 50;
20
+ export const MAX_CATCHUP_PAGE_SIZE = 500;
21
+ /** Env: bounded backfill on first watermark. Unset = 24h (retired window, once). `0` = "now". */
22
+ export const CATCHUP_BACKFILL_MS_ENV = "FLAIR_CATCHUP_BACKFILL_MS";
23
+ export const DEFAULT_CATCHUP_BACKFILL_MS = 24 * 3600_000;
24
+ export function readPositionId(agentId, stream) {
25
+ return `${agentId}:${stream}`;
26
+ }
27
+ /** Total-order position for a record that has `createdAt` + `id`. */
28
+ export function recordPosition(record) {
29
+ return `${record.createdAt ?? ""}${POSITION_SEP}${record.id ?? ""}`;
30
+ }
31
+ /** Exclusive cursor just after every record at-or-before this ISO timestamp. */
32
+ export function positionAfterTimestamp(iso) {
33
+ return `${iso}${POSITION_SEP}`;
34
+ }
35
+ export function comparePosition(a, b) {
36
+ return compareKey(a, b);
37
+ }
38
+ /** Leading createdAt of a position, or null if the cursor has no timestamp. */
39
+ export function createdAtFloorFromPosition(position) {
40
+ if (!position)
41
+ return null;
42
+ const sep = position.indexOf(POSITION_SEP);
43
+ const ts = sep === -1 ? position : position.slice(0, sep);
44
+ return ts || null;
45
+ }
46
+ export function laterTimestamp(a, b) {
47
+ if (!a)
48
+ return b || null;
49
+ if (!b)
50
+ return a;
51
+ return a >= b ? a : b;
52
+ }
53
+ export function parseBackfillMs(raw = process.env[CATCHUP_BACKFILL_MS_ENV]) {
54
+ if (raw === undefined || raw === "")
55
+ return DEFAULT_CATCHUP_BACKFILL_MS;
56
+ const n = Number(raw);
57
+ if (!Number.isFinite(n) || n < 0)
58
+ return DEFAULT_CATCHUP_BACKFILL_MS;
59
+ return n;
60
+ }
61
+ /**
62
+ * Sane first watermark. Default a 24h bounded backfill (the retired bootstrap
63
+ * window, applied once); `FLAIR_CATCHUP_BACKFILL_MS=0` is Flint's "now"
64
+ * (fresh agent does not replay history). After init the durable position governs.
65
+ */
66
+ export function initialPosition(nowMs = Date.now(), backfillMs = parseBackfillMs()) {
67
+ const floor = Math.max(0, nowMs - Math.max(0, backfillMs));
68
+ return positionAfterTimestamp(new Date(floor).toISOString());
69
+ }
70
+ export function parsePageSize(raw, fallback = DEFAULT_CATCHUP_PAGE_SIZE, max = MAX_CATCHUP_PAGE_SIZE) {
71
+ const n = typeof raw === "number" ? raw : raw != null && raw !== "" ? Number(raw) : fallback;
72
+ if (!Number.isFinite(n))
73
+ return fallback;
74
+ return Math.min(max, Math.max(1, Math.floor(n)));
75
+ }
76
+ /** Exclusive `after`, already-sorted by position ascending. Never silent-truncates. */
77
+ export function pageAfter(ordered, after, pageSize) {
78
+ const slice = ordered.filter((row) => comparePosition(row.position, after) > 0);
79
+ const hasMore = slice.length > pageSize;
80
+ const page = hasMore ? slice.slice(0, pageSize) : slice;
81
+ const nextAfter = page.length > 0 ? page[page.length - 1].position : after;
82
+ return { page, hasMore, nextAfter };
83
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * agent-read-position.ts — durable per-agent read-position (watermark) store.
3
+ *
4
+ * Storage + monotonic advance for resources/agent-read-position-lib.ts.
5
+ * Owner-scoping lives in the HTTP resource / catch-up handler; this module
6
+ * is the internal contract for raw table access (Harper table I/O bypasses
7
+ * Resource rules).
8
+ *
9
+ * Advance is at-least-once (advance-on-ack): only move forward, never back.
10
+ * Same-process concurrent advances serialize on a per-id promise tail so two
11
+ * acks cannot clobber a higher watermark with a lower one.
12
+ */
13
+ import { databases } from "harper";
14
+ import { withDetachedTxn } from "./table-helpers.js";
15
+ import { comparePosition, readPositionId } from "./agent-read-position-lib.js";
16
+ export function defaultReadPositionTable() {
17
+ try {
18
+ const table = databases.flair?.AgentReadPosition;
19
+ if (!table || typeof table.get !== "function" || typeof table.put !== "function")
20
+ return null;
21
+ return table;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ const tails = new Map();
28
+ function enqueue(id, work) {
29
+ const prev = tails.get(id) ?? Promise.resolve();
30
+ const next = prev.then(work, work);
31
+ tails.set(id, next);
32
+ return next.finally(() => {
33
+ if (tails.get(id) === next)
34
+ tails.delete(id);
35
+ });
36
+ }
37
+ async function readRow(table, id, ctx) {
38
+ try {
39
+ const row = await withDetachedTxn(ctx, () => table.get(id));
40
+ return row && typeof row.position === "string" ? row : null;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export async function getReadPosition(table, agentId, stream, ctx) {
47
+ if (!table)
48
+ return null;
49
+ const row = await readRow(table, readPositionId(agentId, stream), ctx);
50
+ return row?.position ?? null;
51
+ }
52
+ /**
53
+ * Return the stored watermark, creating it at `initial` when absent.
54
+ * Concurrent first-writers serialize; the stored row wins if one appears.
55
+ */
56
+ export async function ensureReadPosition(table, agentId, stream, initial, ctx, now = () => new Date().toISOString()) {
57
+ if (!table)
58
+ return initial;
59
+ const id = readPositionId(agentId, stream);
60
+ return enqueue(id, async () => {
61
+ const existing = await readRow(table, id, ctx);
62
+ if (existing)
63
+ return existing.position;
64
+ const row = {
65
+ id,
66
+ agentId,
67
+ stream,
68
+ position: initial,
69
+ updatedAt: now(),
70
+ };
71
+ try {
72
+ await withDetachedTxn(ctx, () => table.put(row));
73
+ }
74
+ catch {
75
+ const raced = await readRow(table, id, ctx);
76
+ if (raced)
77
+ return raced.position;
78
+ return initial;
79
+ }
80
+ return initial;
81
+ });
82
+ }
83
+ /**
84
+ * Monotonic advance. A lower-or-equal ack is a no-op. Serialized per
85
+ * (agent, stream) so concurrent catch-up acks cannot regress the watermark.
86
+ */
87
+ export async function advanceReadPosition(table, agentId, stream, position, ctx, now = () => new Date().toISOString()) {
88
+ if (!position) {
89
+ const current = table ? await getReadPosition(table, agentId, stream, ctx) : null;
90
+ return { position: current ?? "", advanced: false };
91
+ }
92
+ if (!table)
93
+ return { position, advanced: false };
94
+ const id = readPositionId(agentId, stream);
95
+ return enqueue(id, async () => {
96
+ const existing = await readRow(table, id, ctx);
97
+ const current = existing?.position ?? "";
98
+ if (current && comparePosition(position, current) <= 0) {
99
+ return { position: current, advanced: false };
100
+ }
101
+ const row = {
102
+ id,
103
+ agentId,
104
+ stream,
105
+ position,
106
+ updatedAt: now(),
107
+ };
108
+ try {
109
+ await withDetachedTxn(ctx, () => table.put(row));
110
+ }
111
+ catch {
112
+ const raced = await readRow(table, id, ctx);
113
+ if (raced && comparePosition(raced.position, position) >= 0) {
114
+ return { position: raced.position, advanced: false };
115
+ }
116
+ return { position: current || position, advanced: false };
117
+ }
118
+ return { position, advanced: true };
119
+ });
120
+ }
@@ -175,6 +175,23 @@ export function resolveEmbedThreads(env = process.env, cores = availableParallel
175
175
  const safeCores = Number.isFinite(cores) && cores >= 1 ? Math.floor(cores) : 1;
176
176
  return Math.max(1, safeCores - 1);
177
177
  }
178
+ /**
179
+ * llama.cpp GPU-layer offload passed to HFE `register({config:{gpuLayers}})`.
180
+ *
181
+ * DEFAULT IS UNCHANGED. Unset / empty / non-integer / negative → `undefined`,
182
+ * and the register() call OMITS the field so HFE keeps its own default of 0
183
+ * (CPU only). This is a measurement pin for the ingest-throughput bench
184
+ * (flair#1436 / #1437), not a product default change. #1437 is the decision
185
+ * about whether to detect-and-default gpuLayers; this function must not
186
+ * pre-empt it.
187
+ *
188
+ * Override: `FLAIR_EMBED_GPU_LAYERS` — a non-negative integer, env-only
189
+ * (same persist-path reason as `FLAIR_EMBED_THREADS`). 0 = CPU only; 99 =
190
+ * full offload (the Metal cell). Invalid values fall through to omit.
191
+ */
192
+ export function resolveEmbedGpuLayers(env = process.env) {
193
+ return parseNonNegativeInt(env.FLAIR_EMBED_GPU_LAYERS);
194
+ }
178
195
  function parsePositiveInt(raw) {
179
196
  if (raw == null)
180
197
  return undefined;
@@ -186,6 +203,17 @@ function parsePositiveInt(raw) {
186
203
  return undefined;
187
204
  return n;
188
205
  }
206
+ function parseNonNegativeInt(raw) {
207
+ if (raw == null)
208
+ return undefined;
209
+ const trimmed = raw.trim();
210
+ if (trimmed === "")
211
+ return undefined;
212
+ const n = Number(trimmed);
213
+ if (!Number.isInteger(n) || n < 0)
214
+ return undefined;
215
+ return n;
216
+ }
189
217
  let registered = false;
190
218
  /**
191
219
  * Register the embedding backend. Idempotent within a process (mirrors
@@ -200,6 +228,9 @@ export async function registerEmbeddingsBackend() {
200
228
  const { register } = await import("harper-fabric-embeddings");
201
229
  const modelPath = benchModelPathOverride();
202
230
  const threads = resolveEmbedThreads();
231
+ // Omit when unset so HFE's default of 0 is unchanged (flair#1436 measurement
232
+ // pin; #1437 is the default-change decision). Never pass a synthesized default.
233
+ const gpuLayers = resolveEmbedGpuLayers();
203
234
  await register({
204
235
  logicalName: LOGICAL_NAME,
205
236
  kind: "embedding",
@@ -208,6 +239,7 @@ export async function registerEmbeddingsBackend() {
208
239
  ? { modelPath, pooling: EMBEDDING_POOLING }
209
240
  : { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING }),
210
241
  threads,
242
+ ...(gpuLayers !== undefined ? { gpuLayers } : {}),
211
243
  },
212
244
  });
213
245
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pure classifier for federation peer liveness — no Harper imports.
3
+ *
4
+ * flair#1499: HealthDetail used to count `status === "connected"` (a value
5
+ * pairing never writes — spokes stay `paired` even after a successful
6
+ * lastSyncAt advance) and then take the oldest lastSyncAt across ALL peers,
7
+ * including revoked. A healthy hub synced a minute ago plus a revoked row
8
+ * from June therefore rendered as "0 connected" and fired
9
+ * "federation peers all disconnected >24h".
10
+ *
11
+ * Three contact states, plus revoked (which never drives the staleness
12
+ * warning). Same class as #988: missing/unreadable evidence is UNKNOWN,
13
+ * never DISCONNECTED.
14
+ *
15
+ * (a) real lastSyncAt within the window → connected
16
+ * (b) real lastSyncAt older than the window → disconnected
17
+ * (c) no parseable lastSyncAt → unknown (never rendered as (b))
18
+ * revoked stored status → revoked (excluded from the >24h warning)
19
+ */
20
+ export const PEER_STALE_MS = 24 * 3600 * 1000;
21
+ export const FEDERATION_PEERS_ALL_DISCONNECTED_WARNING = "federation peers all disconnected >24h";
22
+ /** Parse a written last-contact stamp. Missing / unparseable → null, never 0/epoch. */
23
+ export function parseLastContactMs(lastSyncAt) {
24
+ if (typeof lastSyncAt !== "string")
25
+ return null;
26
+ const trimmed = lastSyncAt.trim();
27
+ if (!trimmed)
28
+ return null;
29
+ const t = Date.parse(trimmed);
30
+ return Number.isFinite(t) ? t : null;
31
+ }
32
+ export function classifyPeerLiveness(peer, nowMs, windowMs = PEER_STALE_MS) {
33
+ if (peer.status === "revoked")
34
+ return "revoked";
35
+ const ts = parseLastContactMs(peer.lastSyncAt);
36
+ if (ts === null)
37
+ return "unknown";
38
+ // Future / equal-now stamps are contact within the window (clock skew).
39
+ if (nowMs - ts > windowMs)
40
+ return "disconnected";
41
+ return "connected";
42
+ }
43
+ export function summarizePeerLiveness(peers, nowMs, windowMs = PEER_STALE_MS) {
44
+ let connected = 0;
45
+ let disconnected = 0;
46
+ let revoked = 0;
47
+ let unknown = 0;
48
+ for (const peer of peers) {
49
+ const liveness = classifyPeerLiveness(peer, nowMs, windowMs);
50
+ if (liveness === "connected")
51
+ connected++;
52
+ else if (liveness === "disconnected")
53
+ disconnected++;
54
+ else if (liveness === "revoked")
55
+ revoked++;
56
+ else
57
+ unknown++;
58
+ }
59
+ const nonRevoked = peers.length - revoked;
60
+ return {
61
+ total: peers.length,
62
+ connected,
63
+ disconnected,
64
+ revoked,
65
+ unknown,
66
+ allNonRevokedDisconnected: nonRevoked > 0 && disconnected === nonRevoked,
67
+ };
68
+ }
69
+ export function federationPeersAllDisconnectedWarning(summary) {
70
+ if (!summary.allNonRevokedDisconnected)
71
+ return null;
72
+ return { level: "warn", message: FEDERATION_PEERS_ALL_DISCONNECTED_WARNING };
73
+ }