@substrat-run/adapter-cloudflare 0.98.1 → 0.102.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/dist/host.js CHANGED
@@ -1,6 +1,6 @@
1
- import { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
1
+ import { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, } from '@substrat-run/kernel';
3
+ import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, globalFetch, } from '@substrat-run/kernel';
4
4
  import { tenantStoreDatabaseName } from './d1.js';
5
5
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
6
6
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
@@ -81,6 +81,10 @@ export class CloudflareScopeHost {
81
81
  moduleIds = new Set();
82
82
  /** Module id → its declared recurring schedules (#383), for `registeredSchedules`/`runDueSchedules`. */
83
83
  moduleSchedules = new Map();
84
+ /** #1232: declared freshness per module. Registered UNCONDITIONALLY — the schedule
85
+ * map is only populated for modules WITH schedules, and hanging freshness off it
86
+ * would drop a freshness-only module at registration. */
87
+ moduleFreshness = new Map();
84
88
  /** Registered (module, version) pairs — the frontier `schemaVersion` counts toward (§5.3, #49). */
85
89
  migrationTotal = 0;
86
90
  operations = new Set();
@@ -122,7 +126,7 @@ export class CloudflareScopeHost {
122
126
  this.tenantStores = options.tenantStores;
123
127
  this.blobStores = options.blobStores;
124
128
  this.attachmentBuckets = options.attachmentBuckets;
125
- this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
129
+ this.fetchImpl = options.fetch ?? globalFetch;
126
130
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
127
131
  this.scopeNs = options.scope;
128
132
  this.cpLess = !options.controlPlane;
@@ -326,6 +330,14 @@ export class CloudflareScopeHost {
326
330
  await this.migrateAndRecord(scopeId);
327
331
  return this.scopeStub(scopeId).executorDeadLetters();
328
332
  }
333
+ /**
334
+ * #1232: hand one pass's schedule outcomes to the scope's own intent journal —
335
+ * the CP-less deployment's only road to `_substrat_sweep_runs`. Null = the
336
+ * journal is full and the report was deliberately dropped, never queued late.
337
+ */
338
+ async enqueueSweepRuns(scopeId, payload) {
339
+ return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
340
+ }
329
341
  async listPlatformRequests(tenantId, scopeId) {
330
342
  await this.cp.validateScopeAccess(tenantId, scopeId);
331
343
  await this.migrateAndRecord(scopeId);
@@ -531,6 +543,9 @@ export class CloudflareScopeHost {
531
543
  if (manifest.schedules && manifest.schedules.length > 0) {
532
544
  this.moduleSchedules.set(manifest.id, manifest.schedules);
533
545
  }
546
+ if (manifest.freshness && manifest.freshness.length > 0) {
547
+ this.moduleFreshness.set(manifest.id, manifest.freshness);
548
+ }
534
549
  this.migrationTotal += migrations.length;
535
550
  const ownOperations = new Set(Object.keys(registration.operations ?? {}));
536
551
  for (const name of manifest.withdraws ?? []) {
@@ -1075,8 +1090,64 @@ export class CloudflareScopeHost {
1075
1090
  }
1076
1091
  return out;
1077
1092
  }
1093
+ registeredFreshness() {
1094
+ const out = [];
1095
+ for (const [moduleId, freshness] of this.moduleFreshness) {
1096
+ if (freshness.length > 0)
1097
+ out.push({ moduleId: moduleId, freshness });
1098
+ }
1099
+ return out;
1100
+ }
1101
+ /**
1102
+ * The freshness evaluator (#1232), CF half — same verdicts and gating as the pure
1103
+ * adapter's, over one `freshnessProbe` round trip to the scope DO. Aggregates
1104
+ * across EVERY registered module (two modules declaring one type share one
1105
+ * gating-state key and one dedupe unit — per-module evaluation would corrupt
1106
+ * both), collapsing duplicates to the tightest window; called once per scope,
1107
+ * any registered module's id as the entry ticket. NOT gated on the system
1108
+ * grant — see the kernel interface doc.
1109
+ */
1110
+ async checkFreshness(moduleId, tenantId, scopeId) {
1111
+ const report = { checks: [] };
1112
+ if (!this.moduleIds.has(moduleId))
1113
+ return report;
1114
+ if (!this.cpLess) {
1115
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1116
+ if (!rec || rec.status !== 'active')
1117
+ return report;
1118
+ }
1119
+ const stub = this.scopeStub(scopeId);
1120
+ const windows = new Map();
1121
+ for (const specs of this.moduleFreshness.values()) {
1122
+ for (const f of specs) {
1123
+ const prev = windows.get(f.eventType);
1124
+ if (prev === undefined || f.within.hours < prev)
1125
+ windows.set(f.eventType, f.within.hours);
1126
+ }
1127
+ }
1128
+ if (windows.size === 0)
1129
+ return report;
1130
+ const probe = await stub.freshnessProbe([...windows.keys()]);
1131
+ const now = Date.now();
1132
+ const nowIso = new Date(now).toISOString();
1133
+ for (const [eventType, withinHours] of windows) {
1134
+ const p = probe[eventType] ?? { observedAt: null, stateAt: null, stateOutcome: null };
1135
+ const outcome = p.observedAt === null
1136
+ ? 'skipped' // never observed — the never-run analogue, not a failure
1137
+ : now - Date.parse(p.observedAt) <= withinHours * 3_600_000
1138
+ ? 'ok'
1139
+ : 'failed';
1140
+ const changed = p.stateOutcome !== outcome;
1141
+ const heartbeatDue = p.stateAt === null || now - Date.parse(p.stateAt) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
1142
+ if (!changed && !heartbeatDue)
1143
+ continue;
1144
+ await stub.recordScheduleRun(`freshness:${eventType}`, nowIso, outcome);
1145
+ report.checks.push({ eventType, outcome, observedAt: p.observedAt, withinHours });
1146
+ }
1147
+ return report;
1148
+ }
1078
1149
  async runDueSchedules(moduleId, tenantId, scopeId) {
1079
- const report = { fired: 0, skipped: 0, failed: 0, errors: [] };
1150
+ const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
1080
1151
  const schedules = this.moduleSchedules.get(moduleId);
1081
1152
  if (!schedules || schedules.length === 0)
1082
1153
  return report;
@@ -1103,6 +1174,7 @@ export class CloudflareScopeHost {
1103
1174
  const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
1104
1175
  if (now < dueAt) {
1105
1176
  report.skipped += 1;
1177
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
1106
1178
  continue;
1107
1179
  }
1108
1180
  let status = 'ok';
@@ -1120,6 +1192,7 @@ export class CloudflareScopeHost {
1120
1192
  });
1121
1193
  }
1122
1194
  await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status);
1195
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
1123
1196
  }
1124
1197
  return report;
1125
1198
  }
@@ -2702,11 +2775,15 @@ export class CloudflareScopeHost {
2702
2775
  tenant_id: entry.tenantId ?? null,
2703
2776
  scope_id: entry.scopeId ?? null,
2704
2777
  vertical: entry.vertical ?? null,
2778
+ version: entry.version ?? null,
2705
2779
  status: entry.status ?? null,
2706
2780
  // Bounded here, not trusted from the catch site: one runaway upstream body
2707
2781
  // must not become a runaway directory row (#559).
2708
2782
  message: entry.message.slice(0, 2000),
2709
2783
  reference: entry.reference ?? null,
2784
+ origin: entry.origin ?? null,
2785
+ code: entry.code ?? null,
2786
+ fingerprint: opsFailureFingerprint(entry),
2710
2787
  at: new Date().toISOString(),
2711
2788
  });
2712
2789
  },
@@ -2715,7 +2792,10 @@ export class CloudflareScopeHost {
2715
2792
  tenantId: filter?.tenantId,
2716
2793
  scopeId: filter?.scopeId,
2717
2794
  vertical: filter?.vertical,
2795
+ version: filter?.version,
2718
2796
  operation: filter?.operation,
2797
+ code: filter?.code,
2798
+ fingerprint: filter?.fingerprint,
2719
2799
  reference: filter?.reference,
2720
2800
  since: filter?.since,
2721
2801
  until: filter?.until,
@@ -2728,6 +2808,67 @@ export class CloudflareScopeHost {
2728
2808
  await this.recordAccess(actor, 'listOpsFailures', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2729
2809
  return rows.map((r) => opsFailureEntry.parse(r));
2730
2810
  },
2811
+ recordSweepRun: async (entry) => {
2812
+ await this.cp.recordSweepRun({
2813
+ id: ulid(),
2814
+ kind: entry.kind,
2815
+ unit: entry.unit,
2816
+ outcome: entry.outcome,
2817
+ tenant_id: entry.tenantId ?? null,
2818
+ scope_id: entry.scopeId ?? null,
2819
+ vertical: entry.vertical ?? null,
2820
+ version: entry.version ?? null,
2821
+ operation: entry.operation ?? null,
2822
+ connection_id: entry.connectionId ?? null,
2823
+ // Bounded here, not trusted from the sweep (the #559 rule).
2824
+ error: entry.error == null ? null : entry.error.slice(0, 2000),
2825
+ elapsed_ms: entry.elapsedMs ?? null,
2826
+ request_id: entry.requestId ?? null,
2827
+ event_type: entry.eventType ?? null,
2828
+ observed_at: entry.observedAt ?? null,
2829
+ at: entry.at ?? new Date().toISOString(),
2830
+ });
2831
+ },
2832
+ listSweepRuns: async (actor, filter) => {
2833
+ const rows = await this.cp.listSweepRuns({
2834
+ kind: filter?.kind,
2835
+ unit: filter?.unit,
2836
+ outcome: filter?.outcome,
2837
+ tenantId: filter?.tenantId,
2838
+ scopeId: filter?.scopeId,
2839
+ vertical: filter?.vertical,
2840
+ connectionId: filter?.connectionId,
2841
+ since: filter?.since,
2842
+ until: filter?.until,
2843
+ limit: filter?.limit,
2844
+ cursor: filter?.cursor,
2845
+ order: filter?.order,
2846
+ });
2847
+ // Rows can name tenants and scopes, so reading them is recorded (K-24).
2848
+ await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2849
+ return rows.map((r) => sweepRunEntry.parse(r));
2850
+ },
2851
+ listIssues: async (actor, filter) => {
2852
+ const rows = await this.cp.listIssues({
2853
+ status: filter?.status,
2854
+ operation: filter?.operation,
2855
+ code: filter?.code,
2856
+ limit: filter?.limit,
2857
+ });
2858
+ // Issues aggregate fleet-wide failures; the read is recorded like the rows' own (K-24).
2859
+ await this.recordAccess(actor, 'listIssues', {}, filter, rows.length);
2860
+ return rows.map((r) => issueEntry.parse(r));
2861
+ },
2862
+ setIssueStatus: async (actor, fingerprint, status) => {
2863
+ const change = await this.cp.setIssueStatus(fingerprint, status, new Date().toISOString());
2864
+ if (!change)
2865
+ return undefined;
2866
+ const before = issueEntry.parse(change.before);
2867
+ const after = issueEntry.parse(change.after);
2868
+ // A lifecycle flip is a staff mutation — audited with the diff (K-33).
2869
+ await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
2870
+ return after;
2871
+ },
2731
2872
  recordModelUsage: async (input) => {
2732
2873
  const l = input.line;
2733
2874
  return this.cp.recordModelUsage({