@clivly/core 0.9.1 → 0.9.3

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.
@@ -31,8 +31,6 @@ const STEP_PREREQUISITES = {
31
31
  function stepIndex(id) {
32
32
  return INTEGRATION_STEPS.indexOf(id);
33
33
  }
34
- /** A heartbeat older than this means the app stopped checking in. */
35
- const HEARTBEAT_STALE_MS = 600 * 1e3;
36
34
  function formatAge(ms) {
37
35
  if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
38
36
  if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
@@ -41,12 +39,96 @@ function formatAge(ms) {
41
39
  function heartbeatAge(facts) {
42
40
  return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
43
41
  }
42
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
43
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 6e4;
44
+ const LIVE_INTERVALS = 2.5;
45
+ const STALE_INTERVALS = 10;
46
+ /**
47
+ * The two boundaries that separate live from stale from offline, in terms of
48
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
49
+ * negative — falls back to the SDK default rather than producing a window of
50
+ * zero that would call every app offline.
51
+ */
52
+ function livenessWindows(intervalMs) {
53
+ const interval = typeof intervalMs === "number" && intervalMs > 0 ? intervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
54
+ return {
55
+ liveMs: interval * LIVE_INTERVALS,
56
+ staleMs: interval * STALE_INTERVALS
57
+ };
58
+ }
59
+ /**
60
+ * Faults worth showing on an app that is up. Deliberately excludes anything the
61
+ * developer simply has not done yet — no schema, no mapping, no trigger are all
62
+ * setup stages, and painting a fresh install amber would make the signal
63
+ * meaningless.
64
+ */
65
+ function livenessIssues(facts) {
66
+ const issues = [...facts.reportedIssues ?? []];
67
+ if (facts.lastRun && !facts.lastRun.ok) issues.push({
68
+ code: "sync_failed",
69
+ message: facts.lastRun.message ?? "The last sync run failed.",
70
+ fix: "Open the run log to see how your app responded."
71
+ });
72
+ return issues;
73
+ }
74
+ function websocketLiveness(facts, presence, base) {
75
+ if (!(presence.connectionState === "connected" && presence.activeInstanceCount > 0)) {
76
+ const health = presence.connectionState === "reconnecting" ? "stale" : "offline";
77
+ return {
78
+ ...base,
79
+ health,
80
+ issues: [],
81
+ reachable: false
82
+ };
83
+ }
84
+ const issues = livenessIssues(facts);
85
+ return {
86
+ ...base,
87
+ health: issues.length > 0 ? "live_with_issues" : "live",
88
+ issues,
89
+ reachable: true
90
+ };
91
+ }
92
+ function deriveLiveness(facts) {
93
+ const ageMs = heartbeatAge(facts);
94
+ const environment = facts.environment ?? null;
95
+ const { liveMs, staleMs } = livenessWindows(facts.heartbeatIntervalMs);
96
+ const base = {
97
+ ageMs,
98
+ ageLabel: ageMs === null ? "No heartbeat yet" : formatAge(ageMs),
99
+ environment,
100
+ ...facts.presence ? { presence: {
101
+ connectionState: facts.presence.connectionState,
102
+ transport: facts.presence.transport
103
+ } } : {}
104
+ };
105
+ if (facts.presence && facts.presence.transport === "websocket") return websocketLiveness(facts, facts.presence, base);
106
+ if (ageMs === null || ageMs >= staleMs) return {
107
+ ...base,
108
+ health: "offline",
109
+ issues: [],
110
+ reachable: false
111
+ };
112
+ if (ageMs >= liveMs) return {
113
+ ...base,
114
+ health: "stale",
115
+ issues: [],
116
+ reachable: false
117
+ };
118
+ const issues = livenessIssues(facts);
119
+ return {
120
+ ...base,
121
+ health: issues.length > 0 ? "live_with_issues" : "live",
122
+ issues,
123
+ reachable: true
124
+ };
125
+ }
44
126
  /** Is the raw condition behind each step met, ignoring prerequisites? */
45
127
  function isSatisfied(id, facts) {
46
128
  const age = heartbeatAge(facts);
47
129
  switch (id) {
48
130
  case "credential": return facts.hasCredential;
49
- case "application": return age !== null && age < HEARTBEAT_STALE_MS;
131
+ case "application": return facts.everConnected || age !== null;
50
132
  case "schema": return facts.discoveredTableCount > 0;
51
133
  case "mapping": return facts.mappedEntityCount > 0;
52
134
  case "trigger": return facts.triggerConfigured;
@@ -69,19 +151,6 @@ function problemFor(id, facts) {
69
151
  message: facts.lastRun.message ?? "The last sync run failed.",
70
152
  fix: "Open the run log to see the host response."
71
153
  };
72
- if (id === "application") {
73
- const age = heartbeatAge(facts);
74
- if (age !== null && age >= HEARTBEAT_STALE_MS) return {
75
- code: "heartbeat_stale",
76
- message: `Your app stopped checking in ${formatAge(age)}.`,
77
- fix: "Start your app, then run `npx clivly status`."
78
- };
79
- if (age === null && facts.everConnected) return {
80
- code: "heartbeat_stale",
81
- message: "Your application stopped checking in.",
82
- fix: "Start your app, then run `npx clivly status`."
83
- };
84
- }
85
154
  return null;
86
155
  }
87
156
  /**
@@ -147,14 +216,12 @@ const STEP_STATUS_WHEN_ACTIVE = {
147
216
  widget: "healthy"
148
217
  };
149
218
  /**
150
- * Collapse the ladder to the single status the surfaces switch on. Live
151
- * conditions — a run in flight, a failure, a heartbeat gone quiet — win over
152
- * ladder position, because they describe the integration as it is now rather
153
- * than how far setup once got.
219
+ * Collapse the ladder to the single status the surfaces switch on. This is the
220
+ * setup dimension only: it answers how far configuration got, never whether the
221
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
154
222
  */
155
223
  function deriveStatus(steps, facts) {
156
224
  if (facts.syncInFlight) return "syncing";
157
- if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
158
225
  if (steps.some((step) => step.state === "failed")) return "error";
159
226
  const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
160
227
  if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
@@ -209,13 +276,13 @@ const NEXT_BY_STEP = {
209
276
  * as the fix for a write problem (C-05). If you need a command for the
210
277
  * next-action slot, call this — do not write another one.
211
278
  */
212
- function nextAction(steps, status) {
279
+ function nextAction(steps, status, liveness) {
213
280
  if (status === "healthy" || status === "syncing") return null;
214
281
  const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
215
282
  if (!blocking) return null;
216
- if (blocking.problem?.code === "heartbeat_stale") return {
217
- label: "Reconnect your app",
218
- command: "npx clivly status",
283
+ if (steps.find((step) => step.id === "application")?.state === "done" && !liveness.reachable) return {
284
+ label: "Start your app",
285
+ command: null,
219
286
  envFile: null
220
287
  };
221
288
  return NEXT_BY_STEP[blocking.id];
@@ -223,20 +290,25 @@ function nextAction(steps, status) {
223
290
  function computeIntegrationState(facts) {
224
291
  const steps = resolveSteps(facts);
225
292
  const status = deriveStatus(steps, facts);
293
+ const liveness = deriveLiveness(facts);
226
294
  return {
227
295
  status,
228
296
  steps,
229
- next: nextAction(steps, status)
297
+ liveness,
298
+ next: nextAction(steps, status, liveness)
230
299
  };
231
300
  }
232
301
  //#endregion
302
+ exports.DEFAULT_HEARTBEAT_INTERVAL_MS = DEFAULT_HEARTBEAT_INTERVAL_MS;
233
303
  exports.INSTALL_COMMAND = INSTALL_COMMAND;
234
304
  exports.INTEGRATION_STEPS = INTEGRATION_STEPS;
235
305
  exports.INTEGRATION_STEP_LABELS = INTEGRATION_STEP_LABELS;
236
306
  exports.STEP_PREREQUISITES = STEP_PREREQUISITES;
237
307
  exports.computeIntegrationState = computeIntegrationState;
308
+ exports.deriveLiveness = deriveLiveness;
238
309
  exports.deriveStatus = deriveStatus;
239
310
  exports.formatAge = formatAge;
311
+ exports.livenessWindows = livenessWindows;
240
312
  exports.nextAction = nextAction;
241
313
  exports.resolveSteps = resolveSteps;
242
314
  exports.stepIndex = stepIndex;
@@ -3,7 +3,13 @@ declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema"
3
3
  type StepId = (typeof INTEGRATION_STEPS)[number];
4
4
  /** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
5
5
  type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
6
- type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
6
+ type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" |
7
+ /**
8
+ * @deprecated No longer derived. Liveness is its own dimension — read
9
+ * `ConnectionHealth` from `deriveLiveness`. Setup progress does not move
10
+ * backwards just because an app stopped running.
11
+ */
12
+ "disconnected";
7
13
  declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
8
14
  /** Each step's prerequisites. Ordering is enforced by a test, not convention. */
9
15
  declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
@@ -13,8 +19,20 @@ interface RunFact {
13
19
  message?: string;
14
20
  ok: boolean;
15
21
  }
22
+ /**
23
+ * Where the reporting app is running, as the app itself understands it.
24
+ *
25
+ * `unknown` is a real value, not an absence: `detectHostEnvironment` refuses to
26
+ * guess when nothing places the app, and a bare `node server.js` with no
27
+ * NODE_ENV must still be able to connect and be shown as itself. Defaulting
28
+ * such a host to `local_dev` would file a production box's faults under local
29
+ * development, where nobody would look for them.
30
+ */
31
+ type HostEnvironment = "local_dev" | "preview" | "production" | "unknown";
16
32
  interface IntegrationFacts {
17
33
  discoveredTableCount: number;
34
+ /** The environment the app reported on its last heartbeat. */
35
+ environment?: HostEnvironment | null;
18
36
  /**
19
37
  * Has this app ever checked in? Distinguishes "never installed" from "was
20
38
  * working, now silent" — the same absent heartbeat means different things,
@@ -22,10 +40,27 @@ interface IntegrationFacts {
22
40
  */
23
41
  everConnected: boolean;
24
42
  hasCredential: boolean;
43
+ /**
44
+ * The cadence the app said it heartbeats at. Liveness windows are multiples
45
+ * of this rather than a fixed constant, so an app that deliberately reports
46
+ * slowly is not called dead for obeying its own configuration.
47
+ */
48
+ heartbeatIntervalMs?: number | null;
25
49
  lastHeartbeatAt: number | null;
26
50
  lastRun: RunFact | null;
27
51
  mappedEntityCount: number;
28
52
  now: number;
53
+ /**
54
+ * Deployment presence, when this deployment has one. Absent or `http`
55
+ * transport keeps the cadence-derived behaviour every SDK in the field uses.
56
+ */
57
+ presence?: DeploymentPresence | null;
58
+ /**
59
+ * Faults the running app diagnosed about itself on its last heartbeat — an
60
+ * unreadable database, a secret missing from the process environment. These
61
+ * are what make a reachable app `live_with_issues`; unfinished setup is not.
62
+ */
63
+ reportedIssues?: StepProblem[];
29
64
  syncInFlight: boolean;
30
65
  /** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
31
66
  triggerConfigured: boolean;
@@ -46,6 +81,52 @@ interface IntegrationStep {
46
81
  state: StepState;
47
82
  }
48
83
  declare function formatAge(ms: number): string;
84
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
85
+ declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
86
+ type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
87
+ /** Which algorithm decides whether this deployment is up. */
88
+ type PresenceTransport = "websocket" | "http";
89
+ type PresenceConnectionState = "connected" | "reconnecting" | "disconnected";
90
+ /**
91
+ * A deployment's connection state as its `PresenceRoom` last persisted it.
92
+ *
93
+ * This exists because a socket has an explicit state and a timestamp does not.
94
+ * Deriving WebSocket liveness from heartbeat age would leave a disconnected app
95
+ * shown as live for 150 seconds and offline only after ten minutes, which is
96
+ * the whole reason this transport exists.
97
+ */
98
+ interface DeploymentPresence {
99
+ /** Sockets currently accepted in the room. */
100
+ activeInstanceCount: number;
101
+ connectionState: PresenceConnectionState;
102
+ transport: PresenceTransport;
103
+ }
104
+ interface ConnectionLiveness {
105
+ /** `"8s ago"`, or `"No heartbeat yet"` when the app has never reported. */
106
+ ageLabel: string;
107
+ ageMs: number | null;
108
+ environment: HostEnvironment | null;
109
+ health: ConnectionHealth;
110
+ /**
111
+ * Faults on a reachable app. Empty unless `health` is `live_with_issues`.
112
+ */
113
+ issues: StepProblem[];
114
+ /** Present when a deployment-presence record supplied this liveness result. */
115
+ presence?: Pick<DeploymentPresence, "connectionState" | "transport">;
116
+ /** Is a heartbeat recent enough to speak for the app as it is right now? */
117
+ reachable: boolean;
118
+ }
119
+ /**
120
+ * The two boundaries that separate live from stale from offline, in terms of
121
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
122
+ * negative — falls back to the SDK default rather than producing a window of
123
+ * zero that would call every app offline.
124
+ */
125
+ declare function livenessWindows(intervalMs: number | null | undefined): {
126
+ liveMs: number;
127
+ staleMs: number;
128
+ };
129
+ declare function deriveLiveness(facts: IntegrationFacts): ConnectionLiveness;
49
130
  /**
50
131
  * Walk the ladder once. A step is `blocked` when an earlier step it needs has
51
132
  * not been satisfied — a prerequisite statement, not a verdict about this step
@@ -54,10 +135,9 @@ declare function formatAge(ms: number): string;
54
135
  */
55
136
  declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
56
137
  /**
57
- * Collapse the ladder to the single status the surfaces switch on. Live
58
- * conditions — a run in flight, a failure, a heartbeat gone quiet — win over
59
- * ladder position, because they describe the integration as it is now rather
60
- * than how far setup once got.
138
+ * Collapse the ladder to the single status the surfaces switch on. This is the
139
+ * setup dimension only: it answers how far configuration got, never whether the
140
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
61
141
  */
62
142
  declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
63
143
  interface NextAction {
@@ -66,6 +146,13 @@ interface NextAction {
66
146
  label: string;
67
147
  }
68
148
  interface IntegrationState {
149
+ /**
150
+ * Is the app running right now. Deliberately alongside `status` rather than
151
+ * folded into it: a surface must be able to say "setup reached mapping" and
152
+ * "the app is offline" in the same breath without either overwriting the
153
+ * other.
154
+ */
155
+ liveness: ConnectionLiveness;
69
156
  next: NextAction | null;
70
157
  status: IntegrationStatus;
71
158
  steps: IntegrationStep[];
@@ -82,7 +169,7 @@ declare const INSTALL_COMMAND = "npx clivly init";
82
169
  * as the fix for a write problem (C-05). If you need a command for the
83
170
  * next-action slot, call this — do not write another one.
84
171
  */
85
- declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
172
+ declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus, liveness: ConnectionLiveness): NextAction | null;
86
173
  declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
87
174
  //#endregion
88
- export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
175
+ export { ConnectionHealth, ConnectionLiveness, DEFAULT_HEARTBEAT_INTERVAL_MS, DeploymentPresence, HostEnvironment, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, PresenceConnectionState, PresenceTransport, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
@@ -3,7 +3,13 @@ declare const INTEGRATION_STEPS: readonly ["credential", "application", "schema"
3
3
  type StepId = (typeof INTEGRATION_STEPS)[number];
4
4
  /** `blocked` means "prerequisite unmet", never "broken" — see C-19. */
5
5
  type StepState = "done" | "active" | "blocked" | "failed" | "skipped";
6
- type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" | "disconnected";
6
+ type IntegrationStatus = "not_configured" | "sdk_installed" | "awaiting_connection" | "connected" | "schema_discovered" | "mapping_required" | "trigger_required" | "ready_to_sync" | "syncing" | "healthy" | "warning" | "error" |
7
+ /**
8
+ * @deprecated No longer derived. Liveness is its own dimension — read
9
+ * `ConnectionHealth` from `deriveLiveness`. Setup progress does not move
10
+ * backwards just because an app stopped running.
11
+ */
12
+ "disconnected";
7
13
  declare const INTEGRATION_STEP_LABELS: Record<StepId, string>;
8
14
  /** Each step's prerequisites. Ordering is enforced by a test, not convention. */
9
15
  declare const STEP_PREREQUISITES: Record<StepId, readonly StepId[]>;
@@ -13,8 +19,20 @@ interface RunFact {
13
19
  message?: string;
14
20
  ok: boolean;
15
21
  }
22
+ /**
23
+ * Where the reporting app is running, as the app itself understands it.
24
+ *
25
+ * `unknown` is a real value, not an absence: `detectHostEnvironment` refuses to
26
+ * guess when nothing places the app, and a bare `node server.js` with no
27
+ * NODE_ENV must still be able to connect and be shown as itself. Defaulting
28
+ * such a host to `local_dev` would file a production box's faults under local
29
+ * development, where nobody would look for them.
30
+ */
31
+ type HostEnvironment = "local_dev" | "preview" | "production" | "unknown";
16
32
  interface IntegrationFacts {
17
33
  discoveredTableCount: number;
34
+ /** The environment the app reported on its last heartbeat. */
35
+ environment?: HostEnvironment | null;
18
36
  /**
19
37
  * Has this app ever checked in? Distinguishes "never installed" from "was
20
38
  * working, now silent" — the same absent heartbeat means different things,
@@ -22,10 +40,27 @@ interface IntegrationFacts {
22
40
  */
23
41
  everConnected: boolean;
24
42
  hasCredential: boolean;
43
+ /**
44
+ * The cadence the app said it heartbeats at. Liveness windows are multiples
45
+ * of this rather than a fixed constant, so an app that deliberately reports
46
+ * slowly is not called dead for obeying its own configuration.
47
+ */
48
+ heartbeatIntervalMs?: number | null;
25
49
  lastHeartbeatAt: number | null;
26
50
  lastRun: RunFact | null;
27
51
  mappedEntityCount: number;
28
52
  now: number;
53
+ /**
54
+ * Deployment presence, when this deployment has one. Absent or `http`
55
+ * transport keeps the cadence-derived behaviour every SDK in the field uses.
56
+ */
57
+ presence?: DeploymentPresence | null;
58
+ /**
59
+ * Faults the running app diagnosed about itself on its last heartbeat — an
60
+ * unreadable database, a secret missing from the process environment. These
61
+ * are what make a reachable app `live_with_issues`; unfinished setup is not.
62
+ */
63
+ reportedIssues?: StepProblem[];
29
64
  syncInFlight: boolean;
30
65
  /** A trigger URL and secret both exist, so Clivly can ask the app to sync. */
31
66
  triggerConfigured: boolean;
@@ -46,6 +81,52 @@ interface IntegrationStep {
46
81
  state: StepState;
47
82
  }
48
83
  declare function formatAge(ms: number): string;
84
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
85
+ declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 60000;
86
+ type ConnectionHealth = "live" | "live_with_issues" | "stale" | "offline";
87
+ /** Which algorithm decides whether this deployment is up. */
88
+ type PresenceTransport = "websocket" | "http";
89
+ type PresenceConnectionState = "connected" | "reconnecting" | "disconnected";
90
+ /**
91
+ * A deployment's connection state as its `PresenceRoom` last persisted it.
92
+ *
93
+ * This exists because a socket has an explicit state and a timestamp does not.
94
+ * Deriving WebSocket liveness from heartbeat age would leave a disconnected app
95
+ * shown as live for 150 seconds and offline only after ten minutes, which is
96
+ * the whole reason this transport exists.
97
+ */
98
+ interface DeploymentPresence {
99
+ /** Sockets currently accepted in the room. */
100
+ activeInstanceCount: number;
101
+ connectionState: PresenceConnectionState;
102
+ transport: PresenceTransport;
103
+ }
104
+ interface ConnectionLiveness {
105
+ /** `"8s ago"`, or `"No heartbeat yet"` when the app has never reported. */
106
+ ageLabel: string;
107
+ ageMs: number | null;
108
+ environment: HostEnvironment | null;
109
+ health: ConnectionHealth;
110
+ /**
111
+ * Faults on a reachable app. Empty unless `health` is `live_with_issues`.
112
+ */
113
+ issues: StepProblem[];
114
+ /** Present when a deployment-presence record supplied this liveness result. */
115
+ presence?: Pick<DeploymentPresence, "connectionState" | "transport">;
116
+ /** Is a heartbeat recent enough to speak for the app as it is right now? */
117
+ reachable: boolean;
118
+ }
119
+ /**
120
+ * The two boundaries that separate live from stale from offline, in terms of
121
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
122
+ * negative — falls back to the SDK default rather than producing a window of
123
+ * zero that would call every app offline.
124
+ */
125
+ declare function livenessWindows(intervalMs: number | null | undefined): {
126
+ liveMs: number;
127
+ staleMs: number;
128
+ };
129
+ declare function deriveLiveness(facts: IntegrationFacts): ConnectionLiveness;
49
130
  /**
50
131
  * Walk the ladder once. A step is `blocked` when an earlier step it needs has
51
132
  * not been satisfied — a prerequisite statement, not a verdict about this step
@@ -54,10 +135,9 @@ declare function formatAge(ms: number): string;
54
135
  */
55
136
  declare function resolveSteps(facts: IntegrationFacts): IntegrationStep[];
56
137
  /**
57
- * Collapse the ladder to the single status the surfaces switch on. Live
58
- * conditions — a run in flight, a failure, a heartbeat gone quiet — win over
59
- * ladder position, because they describe the integration as it is now rather
60
- * than how far setup once got.
138
+ * Collapse the ladder to the single status the surfaces switch on. This is the
139
+ * setup dimension only: it answers how far configuration got, never whether the
140
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
61
141
  */
62
142
  declare function deriveStatus(steps: IntegrationStep[], facts: IntegrationFacts): IntegrationStatus;
63
143
  interface NextAction {
@@ -66,6 +146,13 @@ interface NextAction {
66
146
  label: string;
67
147
  }
68
148
  interface IntegrationState {
149
+ /**
150
+ * Is the app running right now. Deliberately alongside `status` rather than
151
+ * folded into it: a surface must be able to say "setup reached mapping" and
152
+ * "the app is offline" in the same breath without either overwriting the
153
+ * other.
154
+ */
155
+ liveness: ConnectionLiveness;
69
156
  next: NextAction | null;
70
157
  status: IntegrationStatus;
71
158
  steps: IntegrationStep[];
@@ -82,7 +169,7 @@ declare const INSTALL_COMMAND = "npx clivly init";
82
169
  * as the fix for a write problem (C-05). If you need a command for the
83
170
  * next-action slot, call this — do not write another one.
84
171
  */
85
- declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus): NextAction | null;
172
+ declare function nextAction(steps: IntegrationStep[], status: IntegrationStatus, liveness: ConnectionLiveness): NextAction | null;
86
173
  declare function computeIntegrationState(facts: IntegrationFacts): IntegrationState;
87
174
  //#endregion
88
- export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
175
+ export { ConnectionHealth, ConnectionLiveness, DEFAULT_HEARTBEAT_INTERVAL_MS, DeploymentPresence, HostEnvironment, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, IntegrationFacts, IntegrationState, IntegrationStatus, IntegrationStep, NextAction, PresenceConnectionState, PresenceTransport, RunFact, STEP_PREREQUISITES, StepId, StepProblem, StepState, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };
@@ -30,8 +30,6 @@ const STEP_PREREQUISITES = {
30
30
  function stepIndex(id) {
31
31
  return INTEGRATION_STEPS.indexOf(id);
32
32
  }
33
- /** A heartbeat older than this means the app stopped checking in. */
34
- const HEARTBEAT_STALE_MS = 600 * 1e3;
35
33
  function formatAge(ms) {
36
34
  if (ms < 6e4) return `${Math.max(1, Math.round(ms / 1e3))}s ago`;
37
35
  if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
@@ -40,12 +38,96 @@ function formatAge(ms) {
40
38
  function heartbeatAge(facts) {
41
39
  return facts.lastHeartbeatAt === null ? null : facts.now - facts.lastHeartbeatAt;
42
40
  }
41
+ /** The cadence `createClivlySDK` uses when the host app configures none. */
42
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 6e4;
43
+ const LIVE_INTERVALS = 2.5;
44
+ const STALE_INTERVALS = 10;
45
+ /**
46
+ * The two boundaries that separate live from stale from offline, in terms of
47
+ * the app's own reported cadence. A cadence we cannot believe — absent, zero,
48
+ * negative — falls back to the SDK default rather than producing a window of
49
+ * zero that would call every app offline.
50
+ */
51
+ function livenessWindows(intervalMs) {
52
+ const interval = typeof intervalMs === "number" && intervalMs > 0 ? intervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
53
+ return {
54
+ liveMs: interval * LIVE_INTERVALS,
55
+ staleMs: interval * STALE_INTERVALS
56
+ };
57
+ }
58
+ /**
59
+ * Faults worth showing on an app that is up. Deliberately excludes anything the
60
+ * developer simply has not done yet — no schema, no mapping, no trigger are all
61
+ * setup stages, and painting a fresh install amber would make the signal
62
+ * meaningless.
63
+ */
64
+ function livenessIssues(facts) {
65
+ const issues = [...facts.reportedIssues ?? []];
66
+ if (facts.lastRun && !facts.lastRun.ok) issues.push({
67
+ code: "sync_failed",
68
+ message: facts.lastRun.message ?? "The last sync run failed.",
69
+ fix: "Open the run log to see how your app responded."
70
+ });
71
+ return issues;
72
+ }
73
+ function websocketLiveness(facts, presence, base) {
74
+ if (!(presence.connectionState === "connected" && presence.activeInstanceCount > 0)) {
75
+ const health = presence.connectionState === "reconnecting" ? "stale" : "offline";
76
+ return {
77
+ ...base,
78
+ health,
79
+ issues: [],
80
+ reachable: false
81
+ };
82
+ }
83
+ const issues = livenessIssues(facts);
84
+ return {
85
+ ...base,
86
+ health: issues.length > 0 ? "live_with_issues" : "live",
87
+ issues,
88
+ reachable: true
89
+ };
90
+ }
91
+ function deriveLiveness(facts) {
92
+ const ageMs = heartbeatAge(facts);
93
+ const environment = facts.environment ?? null;
94
+ const { liveMs, staleMs } = livenessWindows(facts.heartbeatIntervalMs);
95
+ const base = {
96
+ ageMs,
97
+ ageLabel: ageMs === null ? "No heartbeat yet" : formatAge(ageMs),
98
+ environment,
99
+ ...facts.presence ? { presence: {
100
+ connectionState: facts.presence.connectionState,
101
+ transport: facts.presence.transport
102
+ } } : {}
103
+ };
104
+ if (facts.presence && facts.presence.transport === "websocket") return websocketLiveness(facts, facts.presence, base);
105
+ if (ageMs === null || ageMs >= staleMs) return {
106
+ ...base,
107
+ health: "offline",
108
+ issues: [],
109
+ reachable: false
110
+ };
111
+ if (ageMs >= liveMs) return {
112
+ ...base,
113
+ health: "stale",
114
+ issues: [],
115
+ reachable: false
116
+ };
117
+ const issues = livenessIssues(facts);
118
+ return {
119
+ ...base,
120
+ health: issues.length > 0 ? "live_with_issues" : "live",
121
+ issues,
122
+ reachable: true
123
+ };
124
+ }
43
125
  /** Is the raw condition behind each step met, ignoring prerequisites? */
44
126
  function isSatisfied(id, facts) {
45
127
  const age = heartbeatAge(facts);
46
128
  switch (id) {
47
129
  case "credential": return facts.hasCredential;
48
- case "application": return age !== null && age < HEARTBEAT_STALE_MS;
130
+ case "application": return facts.everConnected || age !== null;
49
131
  case "schema": return facts.discoveredTableCount > 0;
50
132
  case "mapping": return facts.mappedEntityCount > 0;
51
133
  case "trigger": return facts.triggerConfigured;
@@ -68,19 +150,6 @@ function problemFor(id, facts) {
68
150
  message: facts.lastRun.message ?? "The last sync run failed.",
69
151
  fix: "Open the run log to see the host response."
70
152
  };
71
- if (id === "application") {
72
- const age = heartbeatAge(facts);
73
- if (age !== null && age >= HEARTBEAT_STALE_MS) return {
74
- code: "heartbeat_stale",
75
- message: `Your app stopped checking in ${formatAge(age)}.`,
76
- fix: "Start your app, then run `npx clivly status`."
77
- };
78
- if (age === null && facts.everConnected) return {
79
- code: "heartbeat_stale",
80
- message: "Your application stopped checking in.",
81
- fix: "Start your app, then run `npx clivly status`."
82
- };
83
- }
84
153
  return null;
85
154
  }
86
155
  /**
@@ -146,14 +215,12 @@ const STEP_STATUS_WHEN_ACTIVE = {
146
215
  widget: "healthy"
147
216
  };
148
217
  /**
149
- * Collapse the ladder to the single status the surfaces switch on. Live
150
- * conditions — a run in flight, a failure, a heartbeat gone quiet — win over
151
- * ladder position, because they describe the integration as it is now rather
152
- * than how far setup once got.
218
+ * Collapse the ladder to the single status the surfaces switch on. This is the
219
+ * setup dimension only: it answers how far configuration got, never whether the
220
+ * app is up. Pair it with `deriveLiveness` for the second half of the picture.
153
221
  */
154
222
  function deriveStatus(steps, facts) {
155
223
  if (facts.syncInFlight) return "syncing";
156
- if (steps.find((step) => step.id === "application")?.problem?.code === "heartbeat_stale") return "disconnected";
157
224
  if (steps.some((step) => step.state === "failed")) return "error";
158
225
  const blocking = steps.find((step) => step.state === "active" || step.state === "blocked");
159
226
  if (blocking) return STEP_STATUS_WHEN_ACTIVE[blocking.id];
@@ -208,13 +275,13 @@ const NEXT_BY_STEP = {
208
275
  * as the fix for a write problem (C-05). If you need a command for the
209
276
  * next-action slot, call this — do not write another one.
210
277
  */
211
- function nextAction(steps, status) {
278
+ function nextAction(steps, status, liveness) {
212
279
  if (status === "healthy" || status === "syncing") return null;
213
280
  const blocking = steps.find((step) => step.state === "failed") ?? steps.find((step) => step.state === "active");
214
281
  if (!blocking) return null;
215
- if (blocking.problem?.code === "heartbeat_stale") return {
216
- label: "Reconnect your app",
217
- command: "npx clivly status",
282
+ if (steps.find((step) => step.id === "application")?.state === "done" && !liveness.reachable) return {
283
+ label: "Start your app",
284
+ command: null,
218
285
  envFile: null
219
286
  };
220
287
  return NEXT_BY_STEP[blocking.id];
@@ -222,11 +289,13 @@ function nextAction(steps, status) {
222
289
  function computeIntegrationState(facts) {
223
290
  const steps = resolveSteps(facts);
224
291
  const status = deriveStatus(steps, facts);
292
+ const liveness = deriveLiveness(facts);
225
293
  return {
226
294
  status,
227
295
  steps,
228
- next: nextAction(steps, status)
296
+ liveness,
297
+ next: nextAction(steps, status, liveness)
229
298
  };
230
299
  }
231
300
  //#endregion
232
- export { INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveStatus, formatAge, nextAction, resolveSteps, stepIndex };
301
+ export { DEFAULT_HEARTBEAT_INTERVAL_MS, INSTALL_COMMAND, INTEGRATION_STEPS, INTEGRATION_STEP_LABELS, STEP_PREREQUISITES, computeIntegrationState, deriveLiveness, deriveStatus, formatAge, livenessWindows, nextAction, resolveSteps, stepIndex };