fullstackgtm 0.44.0 → 0.46.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/CHANGELOG.md +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared progress-event vocabulary — ONE set of semantics for every
|
|
3
|
+
* long-running process, rendered natively by each surface:
|
|
4
|
+
*
|
|
5
|
+
* CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
|
|
6
|
+
* app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
|
|
7
|
+
* both → optional broker streaming (a paired CLI POSTs heartbeats to the
|
|
8
|
+
* hosted app under its clientRunId, so long local runs tick live in
|
|
9
|
+
* the org's activity feed)
|
|
10
|
+
*
|
|
11
|
+
* Renderer-agnostic by contract: no event assumes persistence (the CLI drops
|
|
12
|
+
* them after painting) or ANSI (the app can't use it). Listeners are
|
|
13
|
+
* best-effort — a throwing renderer must never fail the work — and the
|
|
14
|
+
* throttle policy lives HERE so both surfaces feel identical.
|
|
15
|
+
*
|
|
16
|
+
* Standing convention: if a verb loops, it emits. New long-running verbs wire
|
|
17
|
+
* an emitter at the package layer; surfaces get feedback for free.
|
|
18
|
+
*/
|
|
19
|
+
export type ProgressEvent = {
|
|
20
|
+
kind: "stage";
|
|
21
|
+
stage: string;
|
|
22
|
+
stageIndex: number;
|
|
23
|
+
stageCount: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "items";
|
|
26
|
+
done: number;
|
|
27
|
+
total?: number;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "note";
|
|
30
|
+
note: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "opResult";
|
|
33
|
+
opId: string;
|
|
34
|
+
status: "applied" | "skipped" | "failed" | "conflict";
|
|
35
|
+
detail?: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "meter";
|
|
38
|
+
spent: number;
|
|
39
|
+
budget: number;
|
|
40
|
+
unit: string;
|
|
41
|
+
};
|
|
42
|
+
/** Accumulated state — what a heartbeat persists / a chip renders. */
|
|
43
|
+
export type ProgressSnapshot = {
|
|
44
|
+
stage?: string;
|
|
45
|
+
stageIndex?: number;
|
|
46
|
+
stageCount?: number;
|
|
47
|
+
itemsDone?: number;
|
|
48
|
+
itemsTotal?: number;
|
|
49
|
+
note?: string;
|
|
50
|
+
opsApplied: number;
|
|
51
|
+
opsSkipped: number;
|
|
52
|
+
opsFailed: number;
|
|
53
|
+
updatedAt: number;
|
|
54
|
+
};
|
|
55
|
+
export type ProgressListener = (event: ProgressEvent, snapshot: ProgressSnapshot) => void;
|
|
56
|
+
export type ProgressEmitter = {
|
|
57
|
+
stage(stage: string, stageIndex: number, stageCount: number): void;
|
|
58
|
+
items(done: number, total?: number): void;
|
|
59
|
+
note(note: string): void;
|
|
60
|
+
opResult(opId: string, status: "applied" | "skipped" | "failed" | "conflict", detail?: string): void;
|
|
61
|
+
meter(spent: number, budget: number, unit: string): void;
|
|
62
|
+
/** Current accumulated state (always fresh, ignoring the throttle). */
|
|
63
|
+
snapshot(): ProgressSnapshot;
|
|
64
|
+
/** Force-deliver the latest state (call at stage ends / completion). */
|
|
65
|
+
flush(): void;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Throttled emitter: `items` heartbeats coalesce to at most one delivery per
|
|
69
|
+
* throttle window (terminal `done === total` always delivers); `stage`,
|
|
70
|
+
* `opResult`, `note`, and `meter` are structural and always deliver.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createProgressEmitter(listener: ProgressListener, options?: {
|
|
73
|
+
throttleMs?: number;
|
|
74
|
+
now?: () => number;
|
|
75
|
+
}): ProgressEmitter;
|
|
76
|
+
/**
|
|
77
|
+
* Fan one event stream out to several listeners (e.g. the CLI's local renderer
|
|
78
|
+
* plus the broker heartbeat streamer). Each listener is isolated: one throwing
|
|
79
|
+
* never starves the others, matching the emitter's own best-effort contract.
|
|
80
|
+
*/
|
|
81
|
+
export declare function composeListeners(...listeners: Array<ProgressListener | undefined | null>): ProgressListener;
|
|
82
|
+
export declare const CRM_SYNC_STAGES: readonly ["owners", "accounts", "contacts", "deals", "counters", "health"];
|
|
83
|
+
/** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
|
|
84
|
+
export declare const SNAPSHOT_PULL_STAGES: readonly ["owners", "accounts", "contacts", "deals"];
|
|
85
|
+
/** The Stripe connector's snapshot pull (billing systems have no owners). */
|
|
86
|
+
export declare const STRIPE_SNAPSHOT_STAGES: readonly ["customers", "subscriptions"];
|
|
87
|
+
export declare const CALL_SYNC_STAGES: readonly ["notes", "transcripts", "insights"];
|
|
88
|
+
export declare const BACKFILL_STRIPE_STAGES: readonly ["invoices", "snapshot", "matching", "plan"];
|
|
89
|
+
/** `backfill runs` — replaying local plan runs + health history to the broker. */
|
|
90
|
+
export declare const RUNS_REPLAY_STAGES: readonly ["runs", "health"];
|
|
91
|
+
export declare const APPLY_STAGES: readonly ["preflight", "operations", "results"];
|
|
92
|
+
/** `enrich acquire` — routing sourced candidate rows into a create plan. */
|
|
93
|
+
export declare const ACQUIRE_STAGES: readonly ["candidates"];
|
|
94
|
+
export declare const MARKET_CAPTURE_STAGES: readonly ["sources", "capture", "classify", "persist"];
|
|
95
|
+
/** No-op emitter for callers that don't care (keeps signatures simple). */
|
|
96
|
+
export declare function nullProgressEmitter(): ProgressEmitter;
|
package/dist/progress.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared progress-event vocabulary — ONE set of semantics for every
|
|
3
|
+
* long-running process, rendered natively by each surface:
|
|
4
|
+
*
|
|
5
|
+
* CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
|
|
6
|
+
* app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
|
|
7
|
+
* both → optional broker streaming (a paired CLI POSTs heartbeats to the
|
|
8
|
+
* hosted app under its clientRunId, so long local runs tick live in
|
|
9
|
+
* the org's activity feed)
|
|
10
|
+
*
|
|
11
|
+
* Renderer-agnostic by contract: no event assumes persistence (the CLI drops
|
|
12
|
+
* them after painting) or ANSI (the app can't use it). Listeners are
|
|
13
|
+
* best-effort — a throwing renderer must never fail the work — and the
|
|
14
|
+
* throttle policy lives HERE so both surfaces feel identical.
|
|
15
|
+
*
|
|
16
|
+
* Standing convention: if a verb loops, it emits. New long-running verbs wire
|
|
17
|
+
* an emitter at the package layer; surfaces get feedback for free.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_THROTTLE_MS = 2000;
|
|
20
|
+
/**
|
|
21
|
+
* Throttled emitter: `items` heartbeats coalesce to at most one delivery per
|
|
22
|
+
* throttle window (terminal `done === total` always delivers); `stage`,
|
|
23
|
+
* `opResult`, `note`, and `meter` are structural and always deliver.
|
|
24
|
+
*/
|
|
25
|
+
export function createProgressEmitter(listener, options = {}) {
|
|
26
|
+
const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
|
|
27
|
+
const now = options.now ?? Date.now;
|
|
28
|
+
const state = {
|
|
29
|
+
opsApplied: 0,
|
|
30
|
+
opsSkipped: 0,
|
|
31
|
+
opsFailed: 0,
|
|
32
|
+
updatedAt: now(),
|
|
33
|
+
};
|
|
34
|
+
let lastItemsDelivery = 0;
|
|
35
|
+
let pendingItems = false;
|
|
36
|
+
function deliver(event) {
|
|
37
|
+
state.updatedAt = now();
|
|
38
|
+
try {
|
|
39
|
+
listener(event, { ...state });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Renderers are presentation-only; never fail the work.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
stage(stage, stageIndex, stageCount) {
|
|
47
|
+
state.stage = stage;
|
|
48
|
+
state.stageIndex = stageIndex;
|
|
49
|
+
state.stageCount = stageCount;
|
|
50
|
+
state.itemsDone = undefined;
|
|
51
|
+
state.itemsTotal = undefined;
|
|
52
|
+
state.note = undefined;
|
|
53
|
+
pendingItems = false;
|
|
54
|
+
deliver({ kind: "stage", stage, stageIndex, stageCount });
|
|
55
|
+
},
|
|
56
|
+
items(done, total) {
|
|
57
|
+
state.itemsDone = done;
|
|
58
|
+
state.itemsTotal = total;
|
|
59
|
+
const terminal = total !== undefined && done >= total;
|
|
60
|
+
const due = now() - lastItemsDelivery >= throttleMs;
|
|
61
|
+
if (terminal || due) {
|
|
62
|
+
lastItemsDelivery = now();
|
|
63
|
+
pendingItems = false;
|
|
64
|
+
deliver({ kind: "items", done, total });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
pendingItems = true;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
note(note) {
|
|
71
|
+
state.note = note;
|
|
72
|
+
deliver({ kind: "note", note });
|
|
73
|
+
},
|
|
74
|
+
opResult(opId, status, detail) {
|
|
75
|
+
if (status === "applied")
|
|
76
|
+
state.opsApplied += 1;
|
|
77
|
+
else if (status === "skipped")
|
|
78
|
+
state.opsSkipped += 1;
|
|
79
|
+
else
|
|
80
|
+
state.opsFailed += 1;
|
|
81
|
+
deliver({ kind: "opResult", opId, status, detail });
|
|
82
|
+
},
|
|
83
|
+
meter(spent, budget, unit) {
|
|
84
|
+
deliver({ kind: "meter", spent, budget, unit });
|
|
85
|
+
},
|
|
86
|
+
snapshot() {
|
|
87
|
+
return { ...state, updatedAt: now() };
|
|
88
|
+
},
|
|
89
|
+
flush() {
|
|
90
|
+
if (pendingItems && state.itemsDone !== undefined) {
|
|
91
|
+
lastItemsDelivery = now();
|
|
92
|
+
pendingItems = false;
|
|
93
|
+
deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Fan one event stream out to several listeners (e.g. the CLI's local renderer
|
|
100
|
+
* plus the broker heartbeat streamer). Each listener is isolated: one throwing
|
|
101
|
+
* never starves the others, matching the emitter's own best-effort contract.
|
|
102
|
+
*/
|
|
103
|
+
export function composeListeners(...listeners) {
|
|
104
|
+
const active = listeners.filter((listener) => Boolean(listener));
|
|
105
|
+
return (event, snapshot) => {
|
|
106
|
+
for (const listener of active) {
|
|
107
|
+
try {
|
|
108
|
+
listener(event, snapshot);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Listeners are presentation/observability only; never fail the work.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// ── Shared stage registries ─────────────────────────────────────────────────
|
|
117
|
+
// Package constants so the CLI checklist and the app StageTimeline render
|
|
118
|
+
// LITERALLY the same stages in the same order — one mental model everywhere.
|
|
119
|
+
export const CRM_SYNC_STAGES = [
|
|
120
|
+
"owners",
|
|
121
|
+
"accounts",
|
|
122
|
+
"contacts",
|
|
123
|
+
"deals",
|
|
124
|
+
"counters",
|
|
125
|
+
"health",
|
|
126
|
+
];
|
|
127
|
+
/** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
|
|
128
|
+
export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"];
|
|
129
|
+
/** The Stripe connector's snapshot pull (billing systems have no owners). */
|
|
130
|
+
export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"];
|
|
131
|
+
export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"];
|
|
132
|
+
export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"];
|
|
133
|
+
/** `backfill runs` — replaying local plan runs + health history to the broker. */
|
|
134
|
+
export const RUNS_REPLAY_STAGES = ["runs", "health"];
|
|
135
|
+
export const APPLY_STAGES = ["preflight", "operations", "results"];
|
|
136
|
+
/** `enrich acquire` — routing sourced candidate rows into a create plan. */
|
|
137
|
+
export const ACQUIRE_STAGES = ["candidates"];
|
|
138
|
+
export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"];
|
|
139
|
+
/** No-op emitter for callers that don't care (keeps signatures simple). */
|
|
140
|
+
export function nullProgressEmitter() {
|
|
141
|
+
return createProgressEmitter(() => { });
|
|
142
|
+
}
|
package/dist/runReport.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ProgressListener } from "./progress.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Per-row finding for the hosted run timeline. IDs + issue type ONLY — no field
|
|
3
4
|
* values ever leave the CLI, keeping the audit's row data on the operator's side
|
|
@@ -28,6 +29,29 @@ export declare function reportFindings(values: RunFinding[]): void;
|
|
|
28
29
|
export declare function reportCrm(value: RunCrm): void;
|
|
29
30
|
/** A command annotates a structured event (plan saved, meter charged, …). */
|
|
30
31
|
export declare function reportEvent(type: string, detail?: string): void;
|
|
32
|
+
/**
|
|
33
|
+
* Arm heartbeat streaming for this invocation. Call once from the entry point
|
|
34
|
+
* (alongside capturing `startedAt`); without it, progress listeners from
|
|
35
|
+
* `progressReporter()` are inert. Safe to call for any command — skip-listed
|
|
36
|
+
* commands simply never stream.
|
|
37
|
+
*/
|
|
38
|
+
export declare function beginRunReport(args: string[], startedAt: number): void;
|
|
39
|
+
/**
|
|
40
|
+
* A ProgressListener that streams the run's progress snapshot to the paired
|
|
41
|
+
* hosted app. Verbs compose it with their local renderer:
|
|
42
|
+
*
|
|
43
|
+
* createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
|
|
44
|
+
*
|
|
45
|
+
* Throttled to one POST per ~5s, first no earlier than ~5s into the run,
|
|
46
|
+
* 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
|
|
47
|
+
* must stay generic ("batch 4/12"); CRM field values never leave the machine.
|
|
48
|
+
*/
|
|
49
|
+
export declare function progressReporter(overrides?: {
|
|
50
|
+
now?: () => number;
|
|
51
|
+
intervalMs?: number;
|
|
52
|
+
minRunMs?: number;
|
|
53
|
+
fetchImpl?: typeof fetch;
|
|
54
|
+
}): ProgressListener;
|
|
31
55
|
/**
|
|
32
56
|
* Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
|
|
33
57
|
* swallows every error. Call once per process from the entry point.
|
package/dist/runReport.js
CHANGED
|
@@ -31,6 +31,15 @@ export function reportCrm(value) {
|
|
|
31
31
|
export function reportEvent(type, detail) {
|
|
32
32
|
events.push({ ts: Date.now(), type, detail });
|
|
33
33
|
}
|
|
34
|
+
// FNV-1a — the stable-id hash used across the package (see backfill.ts).
|
|
35
|
+
function fnv1a(value) {
|
|
36
|
+
let hash = 0x811c9dc5;
|
|
37
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
38
|
+
hash ^= value.charCodeAt(i);
|
|
39
|
+
hash = Math.imul(hash, 0x01000193);
|
|
40
|
+
}
|
|
41
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
42
|
+
}
|
|
34
43
|
// Setup/inspection verbs aren't interesting as "runs" — skip the noise.
|
|
35
44
|
const SKIP_COMMANDS = new Set([
|
|
36
45
|
"login",
|
|
@@ -43,25 +52,151 @@ const SKIP_COMMANDS = new Set([
|
|
|
43
52
|
"--version",
|
|
44
53
|
"-v",
|
|
45
54
|
]);
|
|
55
|
+
/** The stable per-invocation identity flushRunReport and heartbeats share. */
|
|
56
|
+
function runIdentity(args, startedAt) {
|
|
57
|
+
const command = args[0];
|
|
58
|
+
if (!command || SKIP_COMMANDS.has(command))
|
|
59
|
+
return null;
|
|
60
|
+
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
61
|
+
const full = `${command}${sub}`;
|
|
62
|
+
return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
|
|
63
|
+
}
|
|
64
|
+
// ── Live progress streaming ─────────────────────────────────────────────────
|
|
65
|
+
// While a long command runs, a paired CLI streams heartbeats to the hosted
|
|
66
|
+
// app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
|
|
67
|
+
// will use, status "in_progress", and the progress emitter's snapshot. The
|
|
68
|
+
// server upserts by clientRunId, so the terminal flush simply wins. Same
|
|
69
|
+
// invariants as flushRunReport: opt-in (paired only), fire-and-forget,
|
|
70
|
+
// time-capped, never affects the command's outcome or output.
|
|
71
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
72
|
+
/** No heartbeat before the run is ~this old — fast commands never stream. */
|
|
73
|
+
const HEARTBEAT_MIN_RUN_MS = 5000;
|
|
74
|
+
const HEARTBEAT_TIMEOUT_MS = 2000;
|
|
75
|
+
let runContext = null;
|
|
76
|
+
let lastHeartbeatAt = 0;
|
|
77
|
+
// Broker credential resolved once per run (undefined = not looked up yet;
|
|
78
|
+
// null = looked up, not paired).
|
|
79
|
+
let heartbeatBroker;
|
|
80
|
+
let inflightHeartbeat = null;
|
|
81
|
+
/**
|
|
82
|
+
* Arm heartbeat streaming for this invocation. Call once from the entry point
|
|
83
|
+
* (alongside capturing `startedAt`); without it, progress listeners from
|
|
84
|
+
* `progressReporter()` are inert. Safe to call for any command — skip-listed
|
|
85
|
+
* commands simply never stream.
|
|
86
|
+
*/
|
|
87
|
+
export function beginRunReport(args, startedAt) {
|
|
88
|
+
lastHeartbeatAt = 0;
|
|
89
|
+
heartbeatBroker = undefined;
|
|
90
|
+
inflightHeartbeat = null;
|
|
91
|
+
const identity = runIdentity(args, startedAt);
|
|
92
|
+
runContext = identity ? { ...identity, startedAt } : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A ProgressListener that streams the run's progress snapshot to the paired
|
|
96
|
+
* hosted app. Verbs compose it with their local renderer:
|
|
97
|
+
*
|
|
98
|
+
* createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
|
|
99
|
+
*
|
|
100
|
+
* Throttled to one POST per ~5s, first no earlier than ~5s into the run,
|
|
101
|
+
* 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
|
|
102
|
+
* must stay generic ("batch 4/12"); CRM field values never leave the machine.
|
|
103
|
+
*/
|
|
104
|
+
export function progressReporter(overrides = {}) {
|
|
105
|
+
const now = overrides.now ?? Date.now;
|
|
106
|
+
const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
107
|
+
const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
|
|
108
|
+
const fetchImpl = overrides.fetchImpl ?? fetch;
|
|
109
|
+
return (_event, snapshot) => {
|
|
110
|
+
try {
|
|
111
|
+
if (!runContext)
|
|
112
|
+
return;
|
|
113
|
+
const at = now();
|
|
114
|
+
if (at - runContext.startedAt < minRunMs)
|
|
115
|
+
return;
|
|
116
|
+
if (at - lastHeartbeatAt < intervalMs)
|
|
117
|
+
return;
|
|
118
|
+
if (heartbeatBroker === undefined) {
|
|
119
|
+
const broker = getCredential("broker");
|
|
120
|
+
heartbeatBroker =
|
|
121
|
+
broker?.baseUrl && broker.accessToken
|
|
122
|
+
? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
|
|
123
|
+
: null; // opt-in: only when paired
|
|
124
|
+
}
|
|
125
|
+
if (!heartbeatBroker)
|
|
126
|
+
return;
|
|
127
|
+
lastHeartbeatAt = at;
|
|
128
|
+
sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Observability is best-effort; never affect the command.
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function sendHeartbeat(fetchImpl, broker, context, snapshot, at) {
|
|
136
|
+
// One heartbeat in flight at a time: a stalled POST is superseded, and the
|
|
137
|
+
// terminal flush aborts any straggler so it can never delay process exit.
|
|
138
|
+
inflightHeartbeat?.abort();
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
inflightHeartbeat = controller;
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
command: context.command,
|
|
148
|
+
clientRunId: context.clientRunId,
|
|
149
|
+
status: "in_progress",
|
|
150
|
+
startedAt: context.startedAt,
|
|
151
|
+
finishedAt: at,
|
|
152
|
+
durationMs: at - context.startedAt,
|
|
153
|
+
progress: {
|
|
154
|
+
// Stage names + counts only — never CRM field values.
|
|
155
|
+
stage: snapshot.stage,
|
|
156
|
+
stageIndex: snapshot.stageIndex,
|
|
157
|
+
stageCount: snapshot.stageCount,
|
|
158
|
+
itemsDone: snapshot.itemsDone,
|
|
159
|
+
itemsTotal: snapshot.itemsTotal,
|
|
160
|
+
note: snapshot.note,
|
|
161
|
+
heartbeatAt: at,
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
signal: controller.signal,
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {
|
|
167
|
+
// fire-and-forget
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
if (inflightHeartbeat === controller)
|
|
172
|
+
inflightHeartbeat = null;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
46
175
|
/**
|
|
47
176
|
* Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
|
|
48
177
|
* swallows every error. Call once per process from the entry point.
|
|
49
178
|
*/
|
|
50
179
|
export async function flushRunReport(args, status, startedAt, error) {
|
|
51
|
-
|
|
52
|
-
|
|
180
|
+
// The terminal upsert supersedes any streaming heartbeat still in flight.
|
|
181
|
+
inflightHeartbeat?.abort();
|
|
182
|
+
inflightHeartbeat = null;
|
|
183
|
+
// Stable per-invocation identity: the server dedupes on clientRunId, so a
|
|
184
|
+
// retried report, an "in_progress" heartbeat, or a later `backfill runs`
|
|
185
|
+
// replay of local history never duplicates.
|
|
186
|
+
const identity = runIdentity(args, startedAt);
|
|
187
|
+
if (!identity)
|
|
53
188
|
return;
|
|
54
189
|
const broker = getCredential("broker");
|
|
55
190
|
if (!broker?.baseUrl || !broker.accessToken)
|
|
56
191
|
return; // opt-in: only when paired
|
|
57
|
-
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
58
192
|
const finishedAt = Date.now();
|
|
59
193
|
try {
|
|
60
194
|
await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
|
|
61
195
|
method: "POST",
|
|
62
196
|
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
63
197
|
body: JSON.stringify({
|
|
64
|
-
command:
|
|
198
|
+
command: identity.command,
|
|
199
|
+
clientRunId: identity.clientRunId,
|
|
65
200
|
status,
|
|
66
201
|
startedAt,
|
|
67
202
|
finishedAt,
|
package/dist/schedule.d.ts
CHANGED
|
@@ -40,9 +40,11 @@ export type ScheduleRunRecord = {
|
|
|
40
40
|
* recorded for visibility but nothing ran. Reasons: "plan_not_approved"
|
|
41
41
|
* (scheduled apply against a plan that is not approved — there is no flag
|
|
42
42
|
* that relaxes this), "not_schedulable" (the stored argv no longer passes
|
|
43
|
-
* the allowlist, e.g. after a hand edit of schedules.json)
|
|
43
|
+
* the allowlist, e.g. after a hand edit of schedules.json),
|
|
44
|
+
* "duplicate_firing" (a cron-triggered run already recorded in this minute
|
|
45
|
+
* — launchd can double-trigger the Vixie dom+dow OR corner).
|
|
44
46
|
*/
|
|
45
|
-
noopReason?: "plan_not_approved" | "not_schedulable";
|
|
47
|
+
noopReason?: "plan_not_approved" | "not_schedulable" | "duplicate_firing";
|
|
46
48
|
};
|
|
47
49
|
export declare function scheduleId(label: string, cron: string, argv: string[], createdAt: string): string;
|
|
48
50
|
/**
|
|
@@ -158,3 +160,79 @@ export declare function renderManagedBlock(profile: string, entries: ScheduleEnt
|
|
|
158
160
|
* Idempotent: re-applying the same block yields identical content.
|
|
159
161
|
*/
|
|
160
162
|
export declare function replaceManagedBlock(existing: string, profile: string, block: string | null): string;
|
|
163
|
+
/**
|
|
164
|
+
* One launchd StartCalendarInterval dict: every present key must match (AND),
|
|
165
|
+
* a missing key is a wildcard, and an array of dicts fires when ANY dict
|
|
166
|
+
* matches. Weekday 0 is Sunday, as in cron.
|
|
167
|
+
*/
|
|
168
|
+
export type LaunchdCalendarInterval = {
|
|
169
|
+
Minute?: number;
|
|
170
|
+
Hour?: number;
|
|
171
|
+
Day?: number;
|
|
172
|
+
Weekday?: number;
|
|
173
|
+
Month?: number;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Compile a cron expression to StartCalendarInterval dicts. Full-range fields
|
|
177
|
+
* (`*`, but also spellings like `0-59`) are omitted (launchd wildcard); the
|
|
178
|
+
* rest cross-product, capped so a dense expression cannot render a megabyte
|
|
179
|
+
* plist. Vixie day semantics: when day-of-month AND day-of-week are both
|
|
180
|
+
* restricted, either may match — launchd ANDs keys within one dict, so that
|
|
181
|
+
* becomes the union of a Day dict set and a Weekday dict set. A date matching
|
|
182
|
+
* both sets makes launchd trigger twice in the same minute; the run entry
|
|
183
|
+
* point drops the second via isDuplicateCronFiring.
|
|
184
|
+
*/
|
|
185
|
+
export declare function cronToCalendarIntervals(cron: CronExpression, cap?: number): LaunchdCalendarInterval[];
|
|
186
|
+
/**
|
|
187
|
+
* True when a cron-triggered run was already recorded in `firedAt`'s minute.
|
|
188
|
+
* The `schedule run` entry point checks this for trigger:cron so a launchd
|
|
189
|
+
* double-trigger (dom+dow OR corner above) records a duplicate_firing no-op
|
|
190
|
+
* instead of running the command twice. Manual runs are never deduplicated.
|
|
191
|
+
*/
|
|
192
|
+
export declare function isDuplicateCronFiring(runs: ScheduleRunRecord[], firedAt: string): boolean;
|
|
193
|
+
/** Reverse-DNS namespace for one profile's agents; install/uninstall own this prefix wholesale. */
|
|
194
|
+
export declare function launchdAgentPrefix(profile: string): string;
|
|
195
|
+
export declare function launchdLabel(profile: string, scheduleEntryId: string): string;
|
|
196
|
+
/**
|
|
197
|
+
* Render one LaunchAgent plist. ProgramArguments is an argv array — launchd
|
|
198
|
+
* execs it directly, no shell, so there is no quoting/injection surface at
|
|
199
|
+
* all (values are XML-escaped for the document, nothing more).
|
|
200
|
+
*/
|
|
201
|
+
export declare function renderLaunchdPlist(options: {
|
|
202
|
+
label: string;
|
|
203
|
+
programArguments: string[];
|
|
204
|
+
calendarIntervals: LaunchdCalendarInterval[];
|
|
205
|
+
environment?: Record<string, string>;
|
|
206
|
+
standardOutPath?: string;
|
|
207
|
+
standardErrorPath?: string;
|
|
208
|
+
}): string;
|
|
209
|
+
export type LaunchdIo = {
|
|
210
|
+
/** Plist file names currently in the agents directory. */
|
|
211
|
+
list(): string[];
|
|
212
|
+
write(fileName: string, content: string): void;
|
|
213
|
+
remove(fileName: string): void;
|
|
214
|
+
/** Load one agent plist into the user's gui domain; throws on failure. */
|
|
215
|
+
bootstrap(fileName: string): void;
|
|
216
|
+
/** Best-effort unload by label; a label that is not loaded is not an error. */
|
|
217
|
+
bootout(label: string): void;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* The real ~/Library/LaunchAgents + launchctl. Everything above takes a
|
|
221
|
+
* LaunchdIo so tests inject fakes and never touch the user's agents.
|
|
222
|
+
*/
|
|
223
|
+
export declare function systemLaunchdIo(agentsDirOverride?: string): LaunchdIo;
|
|
224
|
+
/**
|
|
225
|
+
* Materialize enabled entries as one LaunchAgent each, replacing the
|
|
226
|
+
* profile's com.fullstackgtm.<profile>.* fleet wholesale (stale agents are
|
|
227
|
+
* booted out and deleted; plists outside the prefix are never touched).
|
|
228
|
+
* `cliArgv` is the argv-array analog of the crontab invocation line.
|
|
229
|
+
*/
|
|
230
|
+
export declare function installLaunchdAgents(profile: string, entries: ScheduleEntry[], cliArgv: string[], io: LaunchdIo, options?: {
|
|
231
|
+
environment?: Record<string, string>;
|
|
232
|
+
logDir?: string;
|
|
233
|
+
}): {
|
|
234
|
+
installed: string[];
|
|
235
|
+
removed: string[];
|
|
236
|
+
};
|
|
237
|
+
/** Boot out and delete every agent in the profile's prefix; returns the removed labels. */
|
|
238
|
+
export declare function uninstallLaunchdAgents(profile: string, io: LaunchdIo): string[];
|