@neat.is/core 0.10.3-dev.20260930 → 0.10.4

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/dist/index.d.cts CHANGED
@@ -126,6 +126,29 @@ declare class Projects {
126
126
  attachSearchIndex(name: string, index: SearchIndex | undefined): void;
127
127
  }
128
128
 
129
+ /** What one pass did: whether the bound-repo list was read, and how the repos it took on came out. */
130
+ interface RepoSyncPassCounts {
131
+ listed: boolean;
132
+ synced: number;
133
+ failed: number;
134
+ }
135
+ /** A finished pass, as the on-demand trigger reports it. */
136
+ interface RepoSyncPassSummary extends RepoSyncPassCounts {
137
+ startedAt: string;
138
+ finishedAt: string;
139
+ }
140
+ /**
141
+ * The answer to "sync now" (#1293). `started` — nothing was running, a pass has begun. `queued` — a pass was
142
+ * already running, and one more will follow it. Joining the running pass outright would be wrong: it read
143
+ * the bound-repo list before this request, so a repo bound since would be missed; the follow-up re-reads the
144
+ * list. Any number of requests during one pass coalesce into that single follow-up. `lastPass` is the most
145
+ * recent pass to finish, absent until one has.
146
+ */
147
+ interface RepoSyncRequestResult {
148
+ status: 'started' | 'queued';
149
+ lastPass?: RepoSyncPassSummary;
150
+ }
151
+
129
152
  /**
130
153
  * A connector implements exactly one method. Everything downstream of
131
154
  * `poll()` — resolving a static call site, minting the OBSERVED edge — is
@@ -293,6 +316,7 @@ interface BuildApiOptions {
293
316
  authToken?: string;
294
317
  trustProxy?: boolean;
295
318
  publicRead?: boolean;
319
+ otlpReceiver?: () => FastifyInstance | undefined;
296
320
  bootstrap?: {
297
321
  status: (name: string) => 'bootstrapping' | 'active' | 'broken' | undefined;
298
322
  list: () => Array<{
@@ -306,6 +330,7 @@ interface BuildApiOptions {
306
330
  path: string;
307
331
  };
308
332
  connectorsHome?: string;
333
+ repoSync?: (project: string) => (() => RepoSyncRequestResult) | undefined;
309
334
  runPoll?: typeof runConnectorPoll;
310
335
  }
311
336
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
@@ -597,6 +622,7 @@ interface ProjectSlot {
597
622
  stopHostedConnectors: () => void;
598
623
  stopK8sSubstrate: () => void;
599
624
  stopRepoSync: () => void;
625
+ syncRepos?: () => RepoSyncRequestResult;
600
626
  detachEvents: () => void;
601
627
  status: 'active' | 'broken';
602
628
  errorReason?: string;
package/dist/index.d.ts CHANGED
@@ -126,6 +126,29 @@ declare class Projects {
126
126
  attachSearchIndex(name: string, index: SearchIndex | undefined): void;
127
127
  }
128
128
 
129
+ /** What one pass did: whether the bound-repo list was read, and how the repos it took on came out. */
130
+ interface RepoSyncPassCounts {
131
+ listed: boolean;
132
+ synced: number;
133
+ failed: number;
134
+ }
135
+ /** A finished pass, as the on-demand trigger reports it. */
136
+ interface RepoSyncPassSummary extends RepoSyncPassCounts {
137
+ startedAt: string;
138
+ finishedAt: string;
139
+ }
140
+ /**
141
+ * The answer to "sync now" (#1293). `started` — nothing was running, a pass has begun. `queued` — a pass was
142
+ * already running, and one more will follow it. Joining the running pass outright would be wrong: it read
143
+ * the bound-repo list before this request, so a repo bound since would be missed; the follow-up re-reads the
144
+ * list. Any number of requests during one pass coalesce into that single follow-up. `lastPass` is the most
145
+ * recent pass to finish, absent until one has.
146
+ */
147
+ interface RepoSyncRequestResult {
148
+ status: 'started' | 'queued';
149
+ lastPass?: RepoSyncPassSummary;
150
+ }
151
+
129
152
  /**
130
153
  * A connector implements exactly one method. Everything downstream of
131
154
  * `poll()` — resolving a static call site, minting the OBSERVED edge — is
@@ -293,6 +316,7 @@ interface BuildApiOptions {
293
316
  authToken?: string;
294
317
  trustProxy?: boolean;
295
318
  publicRead?: boolean;
319
+ otlpReceiver?: () => FastifyInstance | undefined;
296
320
  bootstrap?: {
297
321
  status: (name: string) => 'bootstrapping' | 'active' | 'broken' | undefined;
298
322
  list: () => Array<{
@@ -306,6 +330,7 @@ interface BuildApiOptions {
306
330
  path: string;
307
331
  };
308
332
  connectorsHome?: string;
333
+ repoSync?: (project: string) => (() => RepoSyncRequestResult) | undefined;
309
334
  runPoll?: typeof runConnectorPoll;
310
335
  }
311
336
  declare function buildApi(opts: BuildApiOptions): Promise<FastifyInstance>;
@@ -597,6 +622,7 @@ interface ProjectSlot {
597
622
  stopHostedConnectors: () => void;
598
623
  stopK8sSubstrate: () => void;
599
624
  stopRepoSync: () => void;
625
+ syncRepos?: () => RepoSyncRequestResult;
600
626
  detachEvents: () => void;
601
627
  status: 'active' | 'broken';
602
628
  errorReason?: string;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  routeSpanToProject,
3
3
  startDaemon
4
- } from "./chunk-DLQNMU3R.js";
4
+ } from "./chunk-2OQVIBZZ.js";
5
5
  import {
6
6
  ProjectNameCollisionError,
7
7
  addProject,
@@ -37,15 +37,15 @@ import {
37
37
  thresholdForEdgeType,
38
38
  touchLastSeen,
39
39
  writeAtomically
40
- } from "./chunk-LNIVRE4L.js";
40
+ } from "./chunk-TUGYIRUT.js";
41
41
  import {
42
42
  startOtelGrpcReceiver
43
- } from "./chunk-5SJHA5ZH.js";
43
+ } from "./chunk-LXQCL4MP.js";
44
44
  import {
45
45
  buildOtelReceiver,
46
46
  logSpanHandler,
47
47
  parseOtlpRequest
48
- } from "./chunk-M7CGATVC.js";
48
+ } from "./chunk-EFBWQIKG.js";
49
49
  export {
50
50
  ProjectNameCollisionError,
51
51
  addProject,
package/dist/neatd.cjs CHANGED
@@ -51,6 +51,10 @@ function assertBindAuthority(host, token) {
51
51
  if (isLoopbackHost2(host)) return;
52
52
  throw new BindAuthorityError(host);
53
53
  }
54
+ function isBearerDelegated(req2) {
55
+ const config = req2.routeOptions?.config;
56
+ return config?.bearerDelegated === true;
57
+ }
54
58
  function mountBearerAuth(app, opts) {
55
59
  if (!opts.token || opts.token.length === 0) return;
56
60
  if (opts.trustProxy) return;
@@ -66,6 +70,10 @@ function mountBearerAuth(app, opts) {
66
70
  done();
67
71
  return;
68
72
  }
73
+ if (isBearerDelegated(req2)) {
74
+ done();
75
+ return;
76
+ }
69
77
  if (publicRead && PUBLIC_READ_METHODS.has(req2.method)) {
70
78
  done();
71
79
  return;
@@ -99,7 +107,7 @@ function readAuthEnv(env = process.env) {
99
107
  publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
100
108
  };
101
109
  }
102
- var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_PATHS, PROJECT_SCOPED_UNAUTH_PATTERN;
110
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_PATHS, PROJECT_SCOPED_UNAUTH_PATTERN, BEARER_DELEGATED;
103
111
  var init_auth = __esm({
104
112
  "src/auth.ts"() {
105
113
  "use strict";
@@ -129,6 +137,7 @@ var init_auth = __esm({
129
137
  PROJECT_SCOPED_UNAUTH_PATTERN = new RegExp(
130
138
  `^/projects/[^/]+/(?:${DEFAULT_UNAUTH_PATHS.map((p) => p.slice(1)).join("|")})$`
131
139
  );
140
+ BEARER_DELEGATED = { bearerDelegated: true };
132
141
  }
133
142
  });
134
143
 
@@ -23518,6 +23527,19 @@ function registerRoutes(scope, ctx) {
23518
23527
  });
23519
23528
  }
23520
23529
  });
23530
+ scope.post("/repo-sync", async (req2, reply) => {
23531
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
23532
+ if (!proj) return;
23533
+ const syncNow = ctx.repoSync?.(proj.name);
23534
+ if (!syncNow) {
23535
+ return reply.code(404).send({
23536
+ error: "repo-sync is not running for this project",
23537
+ project: proj.name,
23538
+ hint: "Only a hosted daemon syncs bound repos. On a local daemon, POST /graph/scan re-extracts the project path."
23539
+ });
23540
+ }
23541
+ return reply.code(202).send({ project: proj.name, ...syncNow() });
23542
+ });
23521
23543
  scope.post("/graph/scan", async (req2, reply) => {
23522
23544
  const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
23523
23545
  if (!proj) return;
@@ -23765,6 +23787,39 @@ function registerRoutes(scope, ctx) {
23765
23787
  }
23766
23788
  });
23767
23789
  }
23790
+ var OTLP_BODY_LIMIT = 16 * 1024 * 1024;
23791
+ function otlpOnRest(getReceiver) {
23792
+ return async function plugin(scope) {
23793
+ scope.removeAllContentTypeParsers();
23794
+ scope.addContentTypeParser(
23795
+ "*",
23796
+ { parseAs: "buffer", bodyLimit: OTLP_BODY_LIMIT },
23797
+ (_req, body, done) => done(null, body)
23798
+ );
23799
+ const relay = async (req2, reply) => {
23800
+ const receiver = getReceiver();
23801
+ if (!receiver) {
23802
+ return reply.code(503).header("retry-after", "1").send({ error: "OTLP receiver is starting" });
23803
+ }
23804
+ const headers = { ...req2.headers };
23805
+ delete headers["content-length"];
23806
+ delete headers["transfer-encoding"];
23807
+ delete headers.connection;
23808
+ const res = await receiver.inject({
23809
+ method: "POST",
23810
+ url: req2.url,
23811
+ headers,
23812
+ payload: req2.body
23813
+ });
23814
+ const contentType = res.headers["content-type"];
23815
+ if (contentType !== void 0) void reply.header("content-type", contentType);
23816
+ return reply.code(res.statusCode).send(res.rawPayload);
23817
+ };
23818
+ const route = { config: BEARER_DELEGATED, bodyLimit: OTLP_BODY_LIMIT };
23819
+ scope.post("/v1/traces", route, relay);
23820
+ scope.post("/projects/:project/v1/traces", route, relay);
23821
+ };
23822
+ }
23768
23823
  async function buildApi(opts) {
23769
23824
  const app = (0, import_fastify2.default)({ logger: false, routerOptions: { maxParamLength: 1024 } });
23770
23825
  await app.register(import_cors.default, { origin: true });
@@ -23783,6 +23838,7 @@ async function buildApi(opts) {
23783
23838
  authProxy: trustProxy === true,
23784
23839
  requiresAuth
23785
23840
  }));
23841
+ if (opts.otlpReceiver) await app.register(otlpOnRest(opts.otlpReceiver));
23786
23842
  const startedAt = opts.startedAt ?? Date.now();
23787
23843
  const registry = buildLegacyRegistry(opts);
23788
23844
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -23813,6 +23869,7 @@ async function buildApi(opts) {
23813
23869
  bootstrap: opts.bootstrap,
23814
23870
  singleProject: opts.singleProject?.name,
23815
23871
  connectorsHome: opts.connectorsHome,
23872
+ repoSync: opts.repoSync,
23816
23873
  runPoll: opts.runPoll ?? runConnectorPoll
23817
23874
  };
23818
23875
  app.get("/health", async () => {
@@ -24371,6 +24428,7 @@ async function syncOneRepo(r, input) {
24371
24428
  detail: `extracted ${nodes} node${nodes === 1 ? "" : "s"}, ${edges} edge${edges === 1 ? "" : "s"}`,
24372
24429
  lastSyncAt: new Date(input.now?.() ?? Date.now()).toISOString()
24373
24430
  });
24431
+ return true;
24374
24432
  } catch (err) {
24375
24433
  input.onError?.(label, err);
24376
24434
  await cpPostStatus(deps, r.owner, r.name, {
@@ -24378,50 +24436,76 @@ async function syncOneRepo(r, input) {
24378
24436
  detail: scrubToken(err.message).slice(0, 300)
24379
24437
  }).catch(() => {
24380
24438
  });
24439
+ return false;
24381
24440
  } finally {
24382
24441
  if (dir) await (0, import_promises2.rm)(dir, { recursive: true, force: true }).catch(() => {
24383
24442
  });
24384
24443
  }
24385
24444
  }
24386
- async function runRepoSyncPass(input) {
24445
+ async function runRepoSyncPassCounted(input) {
24446
+ const counts = { listed: false, synced: 0, failed: 0 };
24387
24447
  let repos;
24388
24448
  try {
24389
24449
  repos = await cpGet2(`/internal/projects/${input.deps.projectId}/repos`, input.deps);
24390
24450
  } catch (err) {
24391
24451
  input.onSkip?.("(all)", `control plane repo list unreadable \u2014 ${err.message}`);
24392
- return false;
24452
+ return counts;
24393
24453
  }
24394
- if (!Array.isArray(repos)) return false;
24454
+ if (!Array.isArray(repos)) return counts;
24455
+ counts.listed = true;
24395
24456
  for (const r of repos) {
24396
24457
  if (!input.forceResync && !needsSync(r)) continue;
24397
- await syncOneRepo(r, input);
24458
+ if (await syncOneRepo(r, input)) counts.synced++;
24459
+ else counts.failed++;
24398
24460
  }
24399
- return true;
24461
+ return counts;
24400
24462
  }
24401
24463
  async function startRepoSync(input) {
24402
24464
  const intervalMs = input.intervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
24465
+ const now = () => input.now?.() ?? Date.now();
24403
24466
  let stopped = false;
24404
- let running = false;
24467
+ let inFlight = null;
24468
+ let again = false;
24469
+ let lastPass;
24405
24470
  let bootResyncDone = false;
24406
- const tick = async () => {
24407
- if (stopped || running) return;
24408
- running = true;
24471
+ const run = async () => {
24409
24472
  try {
24410
- const listed = await runRepoSyncPass({ ...input, forceResync: !bootResyncDone });
24411
- if (listed) bootResyncDone = true;
24473
+ do {
24474
+ again = false;
24475
+ const startedAt = new Date(now()).toISOString();
24476
+ const counts = await runRepoSyncPassCounted({ ...input, forceResync: !bootResyncDone });
24477
+ if (counts.listed) bootResyncDone = true;
24478
+ lastPass = { ...counts, startedAt, finishedAt: new Date(now()).toISOString() };
24479
+ } while (again && !stopped);
24412
24480
  } finally {
24413
- running = false;
24481
+ inFlight = null;
24414
24482
  }
24415
24483
  };
24416
- void tick();
24417
- const timer = setInterval(() => {
24418
- void tick();
24419
- }, intervalMs);
24484
+ const tick = () => {
24485
+ if (stopped || inFlight) return;
24486
+ inFlight = run();
24487
+ };
24488
+ tick();
24489
+ const timer = setInterval(tick, intervalMs);
24420
24490
  if (typeof timer.unref === "function") timer.unref();
24421
- return () => {
24491
+ const stop = () => {
24422
24492
  stopped = true;
24423
24493
  clearInterval(timer);
24424
24494
  };
24495
+ return Object.assign(stop, {
24496
+ syncNow: () => {
24497
+ const summary = lastPass ? { lastPass } : {};
24498
+ if (inFlight) {
24499
+ again = true;
24500
+ return { status: "queued", ...summary };
24501
+ }
24502
+ inFlight = run();
24503
+ return { status: "started", ...summary };
24504
+ },
24505
+ settled: async () => {
24506
+ while (inFlight) await inFlight;
24507
+ }
24508
+ });
24425
24509
  }
24426
24510
  async function maybeStartRepoSync(input) {
24427
24511
  const env = input.env ?? process.env;
@@ -25191,6 +25275,7 @@ async function bootstrapProject(entry2, connectors = [], neatHome4) {
25191
25275
  stopHostedConnectors,
25192
25276
  stopK8sSubstrate,
25193
25277
  stopRepoSync,
25278
+ ...stopRepoSync.syncNow ? { syncRepos: stopRepoSync.syncNow } : {},
25194
25279
  detachEvents,
25195
25280
  status: "active"
25196
25281
  };
@@ -25462,7 +25547,14 @@ async function startDaemon(opts = {}) {
25462
25547
  // through the same resolved home the slot bootstrap read it from, so a
25463
25548
  // daemon given an explicit NEAT_HOME serves status for the same file it
25464
25549
  // polls.
25465
- connectorsHome: home
25550
+ connectorsHome: home,
25551
+ // #1293 — "sync this project's repos now". Looked up per request: the
25552
+ // slot, and with it the trigger, appears once bootstrap has run.
25553
+ repoSync: (name) => slots.get(name)?.syncRepos,
25554
+ // OTLP answers on this listener too, so a host that routes a single
25555
+ // port still delivers a tenant app's spans. Bound late: the receiver
25556
+ // is built below, once the REST bind has succeeded.
25557
+ otlpReceiver: () => otlpApp ?? void 0
25466
25558
  });
25467
25559
  restAddress = await restApp.listen({ port: restPort, host });
25468
25560
  console.log(
@@ -25643,7 +25735,9 @@ async function startDaemon(opts = {}) {
25643
25735
  }
25644
25736
  });
25645
25737
  otlpAddress = await listenSteppingOtlp(otlpApp, otlpPort, host);
25646
- console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
25738
+ console.log(
25739
+ `neatd: OTLP listening on http://${host}:${portFromListenAddress(otlpAddress, otlpPort)}/v1/traces (also at /v1/traces on the REST port)`
25740
+ );
25647
25741
  } catch (err) {
25648
25742
  for (const slot of slots.values()) {
25649
25743
  teardownSlot(slot);