fullstackgtm 0.23.1 → 0.24.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 +64 -0
- package/README.md +36 -0
- package/dist/cli.js +399 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schedule.d.ts +143 -0
- package/dist/schedule.js +485 -0
- package/docs/api.md +51 -9
- package/llms.txt +44 -0
- package/package.json +1 -1
- package/src/cli.ts +447 -2
- package/src/index.ts +26 -0
- package/src/schedule.ts +609 -0
package/docs/api.md
CHANGED
|
@@ -59,8 +59,10 @@ release.
|
|
|
59
59
|
|
|
60
60
|
Commands: `login` / `logout`, `snapshot`, `audit`, `report`, `diff`, `merge`, `plans`,
|
|
61
61
|
`apply`, `suggest`, `call` (`parse` / `score` / `link` / `plan`), `resolve`,
|
|
62
|
+
`bulk-update`, `dedupe`, `reassign`, `fix`,
|
|
62
63
|
`market` (`init` / `capture` / `classify` / `worksheet` / `observe` / `fronts` /
|
|
63
|
-
`axes` / `
|
|
64
|
+
`axes` / `overlay` / `scale` / `report` / `refresh`),
|
|
65
|
+
`enrich` (`append` / `refresh` / `ingest` / `status`), `rules`, `profiles`, `doctor`.
|
|
64
66
|
Exit codes: `0` success · `1` error · `2` findings/regressions at the requested gate
|
|
65
67
|
(`--fail-on`, `--fail-on-new-findings`). `--json` everywhere; JSON output shapes are stable.
|
|
66
68
|
|
|
@@ -80,19 +82,59 @@ deliverable in markdown or self-contained HTML: severity counts, prose summary,
|
|
|
80
82
|
per-rule detail with capped examples, and next steps. `auditReportToMarkdown` /
|
|
81
83
|
`auditReportToHtml` expose the same rendering programmatically.
|
|
82
84
|
|
|
85
|
+
## Governed write verbs
|
|
86
|
+
|
|
87
|
+
Plan builders behind `bulk-update`, `dedupe`, and `reassign` — every one
|
|
88
|
+
emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
89
|
+
|
|
90
|
+
- `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
|
|
91
|
+
`parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
|
|
92
|
+
`:notempty`, `|` any-of, relational pseudo-fields) and
|
|
93
|
+
`isFilterableField`. Filters are re-verified per record at apply time;
|
|
94
|
+
`from:<sourceField>` values derive per record from the snapshot.
|
|
95
|
+
- `buildDedupePlan(snapshot, options: DedupeOptions)` with `dedupeKey` —
|
|
96
|
+
duplicate groups by normalized identity key, one `merge_records` per group,
|
|
97
|
+
deterministic survivor selection (`richest` / `oldest`).
|
|
98
|
+
- `buildReassignPlans(snapshot, options: ReassignOptions)` — one plan per
|
|
99
|
+
`ReassignObjectType`, account-lifted scoping, stage exclusions.
|
|
100
|
+
|
|
101
|
+
`fix` is CLI-only composition of existing surfaces (audit → suggest →
|
|
102
|
+
approve → apply for one rule).
|
|
103
|
+
|
|
104
|
+
## Enrich
|
|
105
|
+
|
|
106
|
+
Governed third-party enrichment (spec-first; `enrich.config.json` validated
|
|
107
|
+
by `parseEnrichConfig` / `loadEnrichConfig`, types `EnrichConfig`,
|
|
108
|
+
`EnrichFieldConfig`, `EnrichSourceConfig`): `buildEnrichPlan` matches staged
|
|
109
|
+
or pulled source records to CRM records (`matchSourceRecord` — ordered keys,
|
|
110
|
+
`MatchOutcome` of matched / unmatched / ambiguous) and emits fill-blanks-only
|
|
111
|
+
operations. `createFileEnrichRunStore` / `EnrichRunStore` is the profile-scoped
|
|
112
|
+
append-only run store (resume cursor, per-record/per-field `enrichedAt`
|
|
113
|
+
stamps read by `latestStamps` / `selectStaleWork`). `parseCsv` is the
|
|
114
|
+
dependency-free CSV intake; the Apollo client (`createApolloClient`,
|
|
115
|
+
`pullApolloRecords`, 429-aware with `Retry-After`) is the first `api`-kind
|
|
116
|
+
source.
|
|
117
|
+
|
|
83
118
|
## Market map
|
|
84
119
|
|
|
85
|
-
Newer surface (0.16–0.
|
|
120
|
+
Newer surface (0.16–0.23); shapes are settling toward the 1.0 contract. A live
|
|
86
121
|
model of the competitive category: claim taxonomy + vendor registry as a
|
|
87
122
|
reviewable `market.config.json` (`MarketConfig`, `MarketClaim`, `MarketVendor`,
|
|
88
|
-
`MarketAxis`), content-addressed
|
|
89
|
-
`loadCaptureTexts`), append-only observations
|
|
90
|
-
`MarketObservation`, `ObservationStore` /
|
|
91
|
-
profile-scoped under `<home>/market/<category>`),
|
|
92
|
-
derivations: `computeFrontStates` / `diffFrontStates`
|
|
93
|
-
`assessAxes` / `pcaTop2` / `axisPosition` (axis discovery),
|
|
123
|
+
`MarketAxis` — axes require `negativePole`/`positivePole` labels), content-addressed
|
|
124
|
+
page captures (`captureMarket`, `loadCaptureTexts`), append-only observations
|
|
125
|
+
(`ObservationSet`, `MarketObservation`, `ObservationStore` /
|
|
126
|
+
`createFileObservationStore` — profile-scoped under `<home>/market/<category>`),
|
|
127
|
+
and deterministic derivations: `computeFrontStates` / `diffFrontStates`
|
|
128
|
+
(front rule v1), `assessAxes` / `pcaTop2` / `axisPosition` (axis discovery),
|
|
129
|
+
`computeDirectives` / `computeOverlayStats` / `directivesToPlan`
|
|
130
|
+
(`market overlay` — observations × CRM snapshot × call corpus →
|
|
131
|
+
OCCUPY/PROMOTE/URGENT/RETREAT `MarketDirective`s, convertible to an
|
|
132
|
+
approval-gated plan of `create_task` operations), `computeScaleIndex` /
|
|
133
|
+
`scaleReportToText` (`market scale` — citable `ScaleSignal`s → within-set,
|
|
134
|
+
ACV-band-stratified revenue estimates with disclosed uncertainty), and
|
|
94
135
|
`marketMapToMarkdown` / `marketMapToHtml` (the field report; renders the
|
|
95
|
-
primary strategic 2×2 when `axes` / `primaryAxes` are configured
|
|
136
|
+
primary strategic 2×2 when `axes` / `primaryAxes` are configured, bubbles
|
|
137
|
+
area-proportional to estimated revenue share when scale signals exist).
|
|
96
138
|
|
|
97
139
|
Intensity readings are proposals: `classifyMarket` (LLM, bring-your-own-key,
|
|
98
140
|
provenance-marked) or `buildWorksheet` + `market observe` (agent/human). Every
|
package/llms.txt
CHANGED
|
@@ -44,9 +44,34 @@ elsewhere). Failed captures read UNOBSERVABLE, never ABSENT. `fronts --diff`
|
|
|
44
44
|
= deterministic front states + drift between runs; `axes` = PCA axis
|
|
45
45
|
discovery + orthogonality screen; `report` = self-contained HTML field
|
|
46
46
|
report; `refresh` = capture → classify → drift → report in one command.
|
|
47
|
+
`overlay --snapshot <crm.json> [--calls <files>]` joins observations to YOUR
|
|
48
|
+
CRM/calls (deterministic mention matching, no LLM) and emits OCCUPY/PROMOTE/
|
|
49
|
+
URGENT/RETREAT directives — each needs ≥1 observation + ≥1 CRM stat with
|
|
50
|
+
sample size; below thresholds = no directive; `--save` = approval-gated
|
|
51
|
+
create_task ops. `scale` = citable scaleSignals (sourceUrl + verbatim quote
|
|
52
|
+
each) → revenue-space estimates calibrated within-set by acvBand, disclosed
|
|
53
|
+
uncertainty; report bubbles ∝ estimated share only when signals exist.
|
|
47
54
|
Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
48
55
|
`fullstackgtm_market_worksheet`, `fullstackgtm_market_observe`.
|
|
49
56
|
|
|
57
|
+
## Key invariants (governed write verbs)
|
|
58
|
+
|
|
59
|
+
`bulk-update <object> --where … (--set|--archive|--create-task)` filters the
|
|
60
|
+
snapshot into a dry-run plan; the FULL filter is re-verified per record at
|
|
61
|
+
apply time (plus mid-apply rechecks); equality filters double as
|
|
62
|
+
preconditions, `--require`/`--guard` add explicit ones, `--max-operations`
|
|
63
|
+
caps blast radius. `--set f=from:<source>` derives per-record values (empty
|
|
64
|
+
source = skip + count, never guess). `--archive` refuses records sharing an
|
|
65
|
+
identity key — merge with `dedupe` instead. `dedupe <object> --key
|
|
66
|
+
<domain|email|name>` = one merge_records op per duplicate group,
|
|
67
|
+
deterministic survivor (`--keep richest|oldest`); merges are irreversible and
|
|
68
|
+
stay low-confidence-capped at approval. `reassign --from <owner> --to
|
|
69
|
+
<owner>` = ownership handoff plans per object type; `--except-deal-stage`
|
|
70
|
+
also excludes records whose account has an open deal in that stage. `fix
|
|
71
|
+
--rule <id>` = audit one rule → suggest → approve at the confidence bar →
|
|
72
|
+
apply only with `--yes`. All four produce plans; none writes outside
|
|
73
|
+
approve → apply.
|
|
74
|
+
|
|
50
75
|
## Key invariants (enrich)
|
|
51
76
|
|
|
52
77
|
`fullstackgtm enrich` is governed enrichment: `append` pulls (Apollo, BYO key
|
|
@@ -62,6 +87,25 @@ CAS). Conflict policy MVP is `never`; `system-only`/`always` are phase 2 and
|
|
|
62
87
|
refused explicitly. Run store (checkpoint + staleness ledger + `status`) is
|
|
63
88
|
profile-scoped under `<home>/enrich/runs`. No cron — scheduling is horizontal.
|
|
64
89
|
|
|
90
|
+
## Key invariants (schedule)
|
|
91
|
+
|
|
92
|
+
`fullstackgtm schedule` is the horizontal scheduler — no feature namespace
|
|
93
|
+
owns cron logic. `add "<command>" --cron "<expr>"` validates the command
|
|
94
|
+
against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
|
|
95
|
+
market capture|refresh, suggest, report, doctor) and the 5-field cron
|
|
96
|
+
expression, but touches no crontab; `install` materializes enabled entries
|
|
97
|
+
into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
|
|
98
|
+
replaced wholesale on re-install, lines outside it untouched; `uninstall`
|
|
99
|
+
removes only the block. Scheduling never auto-approves: `apply` is
|
|
100
|
+
schedulable ONLY as `apply --plan-id <id>` and every firing re-checks the
|
|
101
|
+
plan is approved — unapproved plans record a `plan_not_approved` no-op run,
|
|
102
|
+
no flag relaxes this. `schedule run <id>` is the single provider entry point
|
|
103
|
+
(in-process dispatch, never shell); run records (exit code, output tail,
|
|
104
|
+
artifacts: plan ids / enrich run labels, trigger cron|manual) land under
|
|
105
|
+
`<home>/schedule/runs/<id>/`. `status` shows next firing and surfaces missed
|
|
106
|
+
firings (visibility only — local cron has no catch-up). Providers beyond
|
|
107
|
+
`local` are not yet implemented (future: codegen scaffolds, same contract).
|
|
108
|
+
|
|
65
109
|
## Key invariants
|
|
66
110
|
|
|
67
111
|
- Reads are safe by default; nothing is written without explicit `--approve`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -101,6 +101,22 @@ import {
|
|
|
101
101
|
pullApolloRecords,
|
|
102
102
|
type ApolloPullKey,
|
|
103
103
|
} from "./enrichApollo.ts";
|
|
104
|
+
import {
|
|
105
|
+
computeMissedFirings,
|
|
106
|
+
createFileScheduleRunStore,
|
|
107
|
+
createFileScheduleStore,
|
|
108
|
+
nextCronFiring,
|
|
109
|
+
parseCron,
|
|
110
|
+
renderManagedBlock,
|
|
111
|
+
replaceManagedBlock,
|
|
112
|
+
scheduleId,
|
|
113
|
+
systemCrontabIo,
|
|
114
|
+
tokenizeCommand,
|
|
115
|
+
validateSchedulableArgv,
|
|
116
|
+
type ScheduleEntry,
|
|
117
|
+
type ScheduleRunRecord,
|
|
118
|
+
type ScheduleRunTrigger,
|
|
119
|
+
} from "./schedule.ts";
|
|
104
120
|
import { resolveRecord, type ResolveCandidate } from "./resolve.ts";
|
|
105
121
|
import { buildBulkUpdatePlan } from "./bulkUpdate.ts";
|
|
106
122
|
import { buildDedupePlan, type DedupeOptions } from "./dedupe.ts";
|
|
@@ -216,6 +232,16 @@ Usage:
|
|
|
216
232
|
value) → with --yes, apply through the provider and print a
|
|
217
233
|
stage-by-stage summary. Without --yes it stops after
|
|
218
234
|
approval and prints the apply command.
|
|
235
|
+
fullstackgtm schedule add "<command>" --cron "<expr>" [--label <name>] [--provider local]
|
|
236
|
+
fullstackgtm schedule list | remove <id> | enable <id> | disable <id> | run <id>
|
|
237
|
+
fullstackgtm schedule install | uninstall [--provider local]
|
|
238
|
+
fullstackgtm schedule status [<id>] [--runs <n>]
|
|
239
|
+
declare a cadence for any read/plan-side command (audit,
|
|
240
|
+
snapshot, enrich append|refresh, market capture|refresh,
|
|
241
|
+
suggest, report, doctor); install materializes enabled
|
|
242
|
+
entries into a sentinel-delimited managed crontab block.
|
|
243
|
+
Scheduling never auto-approves: apply is schedulable only
|
|
244
|
+
as apply --plan-id <id>, re-checked approved at run time.
|
|
219
245
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
220
246
|
derive values for requires_human_* placeholders
|
|
221
247
|
from snapshot evidence, with confidence + reasons
|
|
@@ -1731,6 +1757,421 @@ async function enrichStatus(store: EnrichRunStore, rest: string[], configFile: s
|
|
|
1731
1757
|
}
|
|
1732
1758
|
}
|
|
1733
1759
|
|
|
1760
|
+
/**
|
|
1761
|
+
* The schedule layer: declarative cadences for read/plan-side commands,
|
|
1762
|
+
* materialized through a provider (MVP: the user crontab), with an
|
|
1763
|
+
* append-only run history. The governance invariant — scheduling never
|
|
1764
|
+
* auto-approves — is enforced at `add` time and re-checked at `run` time.
|
|
1765
|
+
* Spec: docs/schedule.md (monorepo).
|
|
1766
|
+
*/
|
|
1767
|
+
async function scheduleCommand(args: string[]) {
|
|
1768
|
+
const [subcommand, ...rest] = args;
|
|
1769
|
+
|
|
1770
|
+
// Catch --help BEFORE any store read, credential access, or crontab
|
|
1771
|
+
// round-trip (the enrich/market early-catch pattern — `schedule install
|
|
1772
|
+
// --help` must never touch the crontab).
|
|
1773
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
1774
|
+
console.log(`Usage:
|
|
1775
|
+
schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]
|
|
1776
|
+
schedule list [--json]
|
|
1777
|
+
schedule remove <id>
|
|
1778
|
+
schedule enable <id> | disable <id>
|
|
1779
|
+
schedule run <id> execute now; the single entry point every provider's timer calls
|
|
1780
|
+
schedule install [--provider local] render enabled entries into the managed crontab block
|
|
1781
|
+
schedule uninstall [--provider local] remove the managed block (nothing else)
|
|
1782
|
+
schedule status [<id>] [--runs <n>] [--json]
|
|
1783
|
+
|
|
1784
|
+
add validates the command against the schedulable allowlist — read/plan-side
|
|
1785
|
+
only: audit, snapshot, enrich append|refresh, market capture|refresh,
|
|
1786
|
+
suggest, report, doctor — and parses the 5-field cron expression (*, lists,
|
|
1787
|
+
ranges, steps), but touches NO crontab: entries are declarative until install
|
|
1788
|
+
materializes them (the plan/apply split — declare, then deliberately
|
|
1789
|
+
activate).
|
|
1790
|
+
|
|
1791
|
+
Scheduling never auto-approves. apply is schedulable ONLY as
|
|
1792
|
+
\`apply --plan-id <id>\`, and every firing re-checks the plan's status is
|
|
1793
|
+
approved — an unapproved plan records a plan_not_approved no-op run instead
|
|
1794
|
+
of executing. There is no flag that relaxes this. Unattended runs accumulate
|
|
1795
|
+
proposals (plans, run records, reports), never surprise CRM writes.
|
|
1796
|
+
|
|
1797
|
+
install renders all enabled entries into a sentinel-delimited block
|
|
1798
|
+
(# >>> fullstackgtm <profile> >>> ... # <<< fullstackgtm <profile> <<<) in
|
|
1799
|
+
the user crontab; each line invokes \`schedule run <id> --profile <profile>\`
|
|
1800
|
+
and nothing else. Re-install replaces the block wholesale and never touches
|
|
1801
|
+
lines outside the sentinels. Providers other than local (modal, aws) are not
|
|
1802
|
+
yet implemented — they will be scaffold generators calling the same
|
|
1803
|
+
\`schedule run\` contract.
|
|
1804
|
+
|
|
1805
|
+
run executes the command in-process and records the outcome (exit code,
|
|
1806
|
+
output tail, artifacts: plan ids / enrich run labels) under the profile's
|
|
1807
|
+
schedule/runs/<id>/; the exit code propagates. Manual runs record
|
|
1808
|
+
trigger: manual. status shows next firing and surfaces missed firings
|
|
1809
|
+
(laptop asleep = cron skips silently; visibility only, no catch-up).`);
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const store = createFileScheduleStore();
|
|
1814
|
+
const runStore = createFileScheduleRunStore();
|
|
1815
|
+
|
|
1816
|
+
if (subcommand === "add") {
|
|
1817
|
+
const commandText = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
1818
|
+
if (!commandText) {
|
|
1819
|
+
throw new Error('Usage: fullstackgtm schedule add "<fullstackgtm command>" --cron "<expr>" [--label <name>] [--provider local]');
|
|
1820
|
+
}
|
|
1821
|
+
const cronText = option(rest, "--cron");
|
|
1822
|
+
if (!cronText) throw new Error('schedule add requires --cron "<minute hour day-of-month month day-of-week>"');
|
|
1823
|
+
requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
|
|
1824
|
+
|
|
1825
|
+
let argv = tokenizeCommand(commandText);
|
|
1826
|
+
if (argv[0] === "fullstackgtm") argv = argv.slice(1);
|
|
1827
|
+
validateSchedulableArgv(argv);
|
|
1828
|
+
const cron = parseCron(cronText);
|
|
1829
|
+
const next = nextCronFiring(cron, new Date()); // also rejects never-firing expressions
|
|
1830
|
+
const createdAt = new Date().toISOString();
|
|
1831
|
+
const label =
|
|
1832
|
+
option(rest, "--label") ??
|
|
1833
|
+
argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
|
|
1834
|
+
const entry: ScheduleEntry = {
|
|
1835
|
+
id: scheduleId(label, cron.source, argv, createdAt),
|
|
1836
|
+
label,
|
|
1837
|
+
argv,
|
|
1838
|
+
cron: cron.source,
|
|
1839
|
+
provider: "local",
|
|
1840
|
+
enabled: true,
|
|
1841
|
+
createdAt,
|
|
1842
|
+
};
|
|
1843
|
+
await store.add(entry);
|
|
1844
|
+
console.log(`Added ${entry.id} "${label}": \`${argv.join(" ")}\` at \`${cron.source}\` (next firing: ${next.toISOString()}).`);
|
|
1845
|
+
console.log("Declarative until materialized — activate with `fullstackgtm schedule install`.");
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (subcommand === "list") {
|
|
1850
|
+
const entries = await store.list();
|
|
1851
|
+
const now = new Date();
|
|
1852
|
+
const withNext = entries.map((entry) => ({
|
|
1853
|
+
...entry,
|
|
1854
|
+
nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
|
|
1855
|
+
}));
|
|
1856
|
+
if (rest.includes("--json")) {
|
|
1857
|
+
console.log(JSON.stringify(withNext, null, 2));
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (entries.length === 0) {
|
|
1861
|
+
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
for (const entry of withNext) {
|
|
1865
|
+
console.log(
|
|
1866
|
+
`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron.padEnd(14)} next ${entry.nextFiring ?? "—"} ${entry.label}: ${entry.argv.join(" ")}`,
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
console.log("\nEntries are declarative — `schedule install` materializes enabled ones into the crontab.");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (subcommand === "remove") {
|
|
1874
|
+
const id = positionalArg(rest, "Usage: fullstackgtm schedule remove <id>");
|
|
1875
|
+
if (!(await store.remove(id))) throw new Error(`No schedule with id ${id}.`);
|
|
1876
|
+
console.log(`Removed ${id}. Run \`fullstackgtm schedule install\` to update the crontab block.`);
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (subcommand === "enable" || subcommand === "disable") {
|
|
1881
|
+
const id = positionalArg(rest, `Usage: fullstackgtm schedule ${subcommand} <id>`);
|
|
1882
|
+
const entry = await store.setEnabled(id, subcommand === "enable");
|
|
1883
|
+
console.log(
|
|
1884
|
+
`${subcommand === "enable" ? "Enabled" : "Disabled"} ${entry.id} "${entry.label}". ` +
|
|
1885
|
+
"Run `fullstackgtm schedule install` to update the crontab block.",
|
|
1886
|
+
);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
if (subcommand === "run") {
|
|
1891
|
+
const id = positionalArg(rest, "Usage: fullstackgtm schedule run <id>");
|
|
1892
|
+
const entry = await store.get(id);
|
|
1893
|
+
if (!entry) throw new Error(`No schedule with id ${id} in profile "${activeProfile()}".`);
|
|
1894
|
+
if (!entry.enabled) {
|
|
1895
|
+
throw new Error(`Schedule ${id} is disabled — enable it with \`fullstackgtm schedule enable ${id}\`.`);
|
|
1896
|
+
}
|
|
1897
|
+
const trigger = (option(rest, "--trigger") ?? "manual") as ScheduleRunTrigger;
|
|
1898
|
+
if (trigger !== "cron" && trigger !== "manual") {
|
|
1899
|
+
throw new Error("--trigger must be cron or manual");
|
|
1900
|
+
}
|
|
1901
|
+
const run = await executeScheduledRun(entry, trigger);
|
|
1902
|
+
if (run.exitCode !== 0) process.exitCode = run.exitCode;
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (subcommand === "install" || subcommand === "uninstall") {
|
|
1907
|
+
requireLocalScheduleProvider(option(rest, "--provider") ?? "local");
|
|
1908
|
+
const profile = activeProfile();
|
|
1909
|
+
const io = systemCrontabIo();
|
|
1910
|
+
const existing = io.read();
|
|
1911
|
+
if (subcommand === "uninstall") {
|
|
1912
|
+
io.write(replaceManagedBlock(existing, profile, null));
|
|
1913
|
+
console.log(`Removed the fullstackgtm managed crontab block for profile "${profile}" (lines outside the sentinels untouched).`);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const enabled = (await store.list()).filter((entry) => entry.enabled && entry.provider === "local");
|
|
1917
|
+
if (enabled.length === 0) {
|
|
1918
|
+
io.write(replaceManagedBlock(existing, profile, null));
|
|
1919
|
+
console.log(`No enabled schedules for profile "${profile}" — removed the managed block.`);
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
const block = renderManagedBlock(profile, enabled, scheduleCliInvocation());
|
|
1923
|
+
io.write(replaceManagedBlock(existing, profile, block));
|
|
1924
|
+
console.log(
|
|
1925
|
+
`Installed ${enabled.length} schedule(s) into the managed crontab block for profile "${profile}". ` +
|
|
1926
|
+
"Re-running install replaces the block wholesale; `schedule uninstall` removes it.",
|
|
1927
|
+
);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
if (subcommand === "status") {
|
|
1932
|
+
const id = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
1933
|
+
const entries = await store.list();
|
|
1934
|
+
const selected = id ? entries.filter((entry) => entry.id === id) : entries;
|
|
1935
|
+
if (id && selected.length === 0) throw new Error(`No schedule with id ${id}.`);
|
|
1936
|
+
if (selected.length === 0) {
|
|
1937
|
+
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const showRuns = numericOption(rest, "--runs");
|
|
1941
|
+
const now = new Date();
|
|
1942
|
+
const report = [] as Array<Record<string, unknown>>;
|
|
1943
|
+
for (const entry of selected) {
|
|
1944
|
+
const runs = await runStore.list(entry.id);
|
|
1945
|
+
const last = runs.length > 0 ? runs[runs.length - 1] : null;
|
|
1946
|
+
let streak = 0;
|
|
1947
|
+
for (let index = runs.length - 1; index >= 0; index -= 1) {
|
|
1948
|
+
if ((runs[index].exitCode === 0) !== (last!.exitCode === 0)) break;
|
|
1949
|
+
streak += 1;
|
|
1950
|
+
}
|
|
1951
|
+
const missed = computeMissedFirings(entry, runs, now);
|
|
1952
|
+
report.push({
|
|
1953
|
+
id: entry.id,
|
|
1954
|
+
label: entry.label,
|
|
1955
|
+
argv: entry.argv,
|
|
1956
|
+
cron: entry.cron,
|
|
1957
|
+
enabled: entry.enabled,
|
|
1958
|
+
nextFiring: entry.enabled ? safeNextFiring(entry.cron, now) : null,
|
|
1959
|
+
lastRun: last
|
|
1960
|
+
? {
|
|
1961
|
+
firedAt: last.firedAt,
|
|
1962
|
+
trigger: last.trigger,
|
|
1963
|
+
exitCode: last.exitCode,
|
|
1964
|
+
noopReason: last.noopReason,
|
|
1965
|
+
artifacts: last.artifacts,
|
|
1966
|
+
}
|
|
1967
|
+
: null,
|
|
1968
|
+
streak: last ? { outcome: last.exitCode === 0 ? "success" : "failure", length: streak } : null,
|
|
1969
|
+
missedFirings: missed.missed.map((firing) => firing.toISOString()),
|
|
1970
|
+
missedFiringsCapped: missed.capped,
|
|
1971
|
+
runs: showRuns !== undefined ? runs.slice(-showRuns) : undefined,
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
if (rest.includes("--json")) {
|
|
1975
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
for (const entry of report) {
|
|
1979
|
+
const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
|
|
1980
|
+
const streak = entry.streak as { outcome: string; length: number } | null;
|
|
1981
|
+
const missed = entry.missedFirings as string[];
|
|
1982
|
+
console.log(`${entry.id} ${entry.enabled ? "on " : "off"} ${entry.cron} ${entry.label}: ${(entry.argv as string[]).join(" ")}`);
|
|
1983
|
+
console.log(` next: ${entry.nextFiring ?? "— (disabled)"}`);
|
|
1984
|
+
if (last) {
|
|
1985
|
+
const artifacts = [
|
|
1986
|
+
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
1987
|
+
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
1988
|
+
];
|
|
1989
|
+
console.log(
|
|
1990
|
+
` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
|
|
1991
|
+
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
1992
|
+
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
|
|
1993
|
+
);
|
|
1994
|
+
console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
|
|
1995
|
+
} else {
|
|
1996
|
+
console.log(" last: never fired");
|
|
1997
|
+
}
|
|
1998
|
+
if (missed.length > 0) {
|
|
1999
|
+
console.log(
|
|
2000
|
+
` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
|
|
2001
|
+
`(latest: ${missed[missed.length - 1]}) — local cron has no catch-up; this is visibility only`,
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
const runs = entry.runs as ScheduleRunRecord[] | undefined;
|
|
2005
|
+
if (runs) {
|
|
2006
|
+
for (const run of runs) {
|
|
2007
|
+
console.log(` ${run.firedAt} ${run.trigger.padEnd(6)} exit ${run.exitCode}${run.noopReason ? ` no-op: ${run.noopReason}` : ""}`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
throw new Error(
|
|
2015
|
+
`Unknown schedule subcommand: ${subcommand} (try: add, list, remove, enable, disable, run, install, uninstall, status)`,
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
function positionalArg(rest: string[], usage: string): string {
|
|
2020
|
+
const value = rest.find((arg) => !arg.startsWith("--") && !isOptionValue(rest, arg));
|
|
2021
|
+
if (!value) throw new Error(usage);
|
|
2022
|
+
return value;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
function safeNextFiring(cron: string, now: Date): string | null {
|
|
2026
|
+
try {
|
|
2027
|
+
return nextCronFiring(parseCron(cron), now).toISOString();
|
|
2028
|
+
} catch {
|
|
2029
|
+
return null;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** The provider seam: local ships now; modal/aws arrive as scaffold generators. */
|
|
2034
|
+
function requireLocalScheduleProvider(provider: string) {
|
|
2035
|
+
if (provider === "local") return;
|
|
2036
|
+
if (provider === "modal" || provider === "aws") {
|
|
2037
|
+
throw new Error(
|
|
2038
|
+
`Provider "${provider}" is not yet implemented — it will be a scaffold generator that emits a ` +
|
|
2039
|
+
"deploy-ready artifact whose runner calls the same `schedule run <id>` contract. MVP provider: local.",
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
throw new Error(`Unknown provider "${provider}" — not yet implemented. MVP provider: local.`);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Resolve how cron should invoke this CLI: the current node binary plus the
|
|
2047
|
+
* entry script (source checkouts need --experimental-strip-types), with
|
|
2048
|
+
* FSGTM_HOME pinned when set so the cron environment sees the same store.
|
|
2049
|
+
*/
|
|
2050
|
+
function scheduleCliInvocation(): string {
|
|
2051
|
+
const script = process.argv[1] ? resolve(process.argv[1]) : null;
|
|
2052
|
+
if (!script || !existsSync(script)) {
|
|
2053
|
+
throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
|
|
2054
|
+
}
|
|
2055
|
+
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2056
|
+
const parts = [quote(process.execPath)];
|
|
2057
|
+
if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
|
|
2058
|
+
parts.push(quote(script));
|
|
2059
|
+
const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
|
|
2060
|
+
return home + parts.join(" ");
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* The single provider entry point: execute the scheduled command in-process
|
|
2065
|
+
* (dispatch into the CLI router — never a shell), capture the output tail and
|
|
2066
|
+
* exit code as a run record, and hand the exit code back to the caller. Cron
|
|
2067
|
+
* calls it, cloud providers will call it, and a human running it manually
|
|
2068
|
+
* produces the same record (trigger: manual).
|
|
2069
|
+
*
|
|
2070
|
+
* Governance re-checks happen here, not just at `add` time: the allowlist is
|
|
2071
|
+
* re-validated (schedules.json is user-editable), and a scheduled apply
|
|
2072
|
+
* hard-checks the plan is approved — an unapproved plan records a
|
|
2073
|
+
* plan_not_approved no-op run and does NOT execute. Scheduling never
|
|
2074
|
+
* auto-approves.
|
|
2075
|
+
*/
|
|
2076
|
+
async function executeScheduledRun(
|
|
2077
|
+
entry: ScheduleEntry,
|
|
2078
|
+
trigger: ScheduleRunTrigger,
|
|
2079
|
+
): Promise<ScheduleRunRecord> {
|
|
2080
|
+
const runStore = createFileScheduleRunStore();
|
|
2081
|
+
const firedAt = new Date().toISOString();
|
|
2082
|
+
const noop = async (
|
|
2083
|
+
reason: NonNullable<ScheduleRunRecord["noopReason"]>,
|
|
2084
|
+
exitCode: number,
|
|
2085
|
+
message: string,
|
|
2086
|
+
): Promise<ScheduleRunRecord> => {
|
|
2087
|
+
const run: ScheduleRunRecord = {
|
|
2088
|
+
scheduleId: entry.id,
|
|
2089
|
+
firedAt,
|
|
2090
|
+
completedAt: new Date().toISOString(),
|
|
2091
|
+
trigger,
|
|
2092
|
+
exitCode,
|
|
2093
|
+
outputTail: message,
|
|
2094
|
+
artifacts: { planIds: [], runLabels: [] },
|
|
2095
|
+
noopReason: reason,
|
|
2096
|
+
};
|
|
2097
|
+
await runStore.record(run);
|
|
2098
|
+
console.error(message);
|
|
2099
|
+
return run;
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
try {
|
|
2103
|
+
validateSchedulableArgv(entry.argv);
|
|
2104
|
+
} catch (error) {
|
|
2105
|
+
return noop("not_schedulable", 1, error instanceof Error ? error.message : String(error));
|
|
2106
|
+
}
|
|
2107
|
+
if (entry.argv[0] === "apply") {
|
|
2108
|
+
const planId = option(entry.argv.slice(1), "--plan-id")!;
|
|
2109
|
+
const stored = await createFilePlanStore().get(planId);
|
|
2110
|
+
const status = stored ? stored.status : "missing";
|
|
2111
|
+
if (status !== "approved") {
|
|
2112
|
+
// The governance gate holding is not a machinery failure: exit 0, with
|
|
2113
|
+
// the refusal recorded on the run for `schedule status` to surface.
|
|
2114
|
+
return noop(
|
|
2115
|
+
"plan_not_approved",
|
|
2116
|
+
0,
|
|
2117
|
+
`Plan ${planId} is ${status}, not approved — scheduled apply refuses to execute. ` +
|
|
2118
|
+
`Scheduling never auto-approves; review and approve with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Artifact attribution by store diff: anything new in the plan store or the
|
|
2124
|
+
// enrich run store after the command ran was produced by this firing.
|
|
2125
|
+
const planStore = createFilePlanStore();
|
|
2126
|
+
const plansBefore = new Set((await planStore.list()).map((stored) => stored.plan.id));
|
|
2127
|
+
const enrichStore = createFileEnrichRunStore();
|
|
2128
|
+
const enrichBefore = new Set((await enrichStore.list()).map((run) => run.runLabel));
|
|
2129
|
+
|
|
2130
|
+
const lines: string[] = [];
|
|
2131
|
+
const originalLog = console.log;
|
|
2132
|
+
const originalError = console.error;
|
|
2133
|
+
const tee =
|
|
2134
|
+
(original: (...args: unknown[]) => void) =>
|
|
2135
|
+
(...args: unknown[]) => {
|
|
2136
|
+
for (const line of args.map(String).join(" ").split("\n")) lines.push(line);
|
|
2137
|
+
original(...args);
|
|
2138
|
+
};
|
|
2139
|
+
console.log = tee(originalLog);
|
|
2140
|
+
console.error = tee(originalError);
|
|
2141
|
+
const priorExitCode = process.exitCode;
|
|
2142
|
+
process.exitCode = undefined;
|
|
2143
|
+
let exitCode = 0;
|
|
2144
|
+
try {
|
|
2145
|
+
await runCli([...entry.argv]);
|
|
2146
|
+
exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
exitCode = 1;
|
|
2149
|
+
lines.push(error instanceof Error ? error.message : String(error));
|
|
2150
|
+
} finally {
|
|
2151
|
+
console.log = originalLog;
|
|
2152
|
+
console.error = originalError;
|
|
2153
|
+
process.exitCode = priorExitCode;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
const planIds = (await planStore.list())
|
|
2157
|
+
.map((stored) => stored.plan.id)
|
|
2158
|
+
.filter((planId) => !plansBefore.has(planId));
|
|
2159
|
+
const runLabels = (await enrichStore.list())
|
|
2160
|
+
.map((run) => run.runLabel)
|
|
2161
|
+
.filter((runLabel) => !enrichBefore.has(runLabel));
|
|
2162
|
+
const run: ScheduleRunRecord = {
|
|
2163
|
+
scheduleId: entry.id,
|
|
2164
|
+
firedAt,
|
|
2165
|
+
completedAt: new Date().toISOString(),
|
|
2166
|
+
trigger,
|
|
2167
|
+
exitCode,
|
|
2168
|
+
outputTail: lines.slice(-50).join("\n"),
|
|
2169
|
+
artifacts: { planIds, runLabels },
|
|
2170
|
+
};
|
|
2171
|
+
await runStore.record(run);
|
|
2172
|
+
return run;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
1734
2175
|
/**
|
|
1735
2176
|
* The resolve gate: exit 0 = safe to create, exit 2 = match found (exists or
|
|
1736
2177
|
* ambiguous — do NOT blind-create), exit 1 = error. Built for sync jobs and
|
|
@@ -2806,8 +3247,8 @@ export async function runCli(argv: string[]) {
|
|
|
2806
3247
|
}
|
|
2807
3248
|
// Commands without bespoke help fall back to the top-level usage on --help
|
|
2808
3249
|
// instead of executing (audit used to silently run the sample audit).
|
|
2809
|
-
// call/market/enrich/bulk-update print their own richer help.
|
|
2810
|
-
if (!["call", "market", "enrich", "bulk-update"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
3250
|
+
// call/market/enrich/bulk-update/schedule print their own richer help.
|
|
3251
|
+
if (!["call", "market", "enrich", "bulk-update", "schedule"].includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
2811
3252
|
console.log(usage());
|
|
2812
3253
|
return;
|
|
2813
3254
|
}
|
|
@@ -2876,6 +3317,10 @@ export async function runCli(argv: string[]) {
|
|
|
2876
3317
|
await enrichCommand(args);
|
|
2877
3318
|
return;
|
|
2878
3319
|
}
|
|
3320
|
+
if (command === "schedule") {
|
|
3321
|
+
await scheduleCommand(args);
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
2879
3324
|
if (command === "profiles") {
|
|
2880
3325
|
profilesCommand(args);
|
|
2881
3326
|
return;
|
package/src/index.ts
CHANGED
|
@@ -254,6 +254,32 @@ export {
|
|
|
254
254
|
type VendorScale,
|
|
255
255
|
} from "./marketScale.ts";
|
|
256
256
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
257
|
+
export {
|
|
258
|
+
computeMissedFirings,
|
|
259
|
+
createFileScheduleRunStore,
|
|
260
|
+
createFileScheduleStore,
|
|
261
|
+
cronMatches,
|
|
262
|
+
crontabSentinels,
|
|
263
|
+
expectedFirings,
|
|
264
|
+
nextCronFiring,
|
|
265
|
+
parseCron,
|
|
266
|
+
renderManagedBlock,
|
|
267
|
+
replaceManagedBlock,
|
|
268
|
+
scheduleId,
|
|
269
|
+
scheduleRunsDir,
|
|
270
|
+
schedulesPath,
|
|
271
|
+
systemCrontabIo,
|
|
272
|
+
tokenizeCommand,
|
|
273
|
+
validateSchedulableArgv,
|
|
274
|
+
type CronExpression,
|
|
275
|
+
type CrontabIo,
|
|
276
|
+
type ScheduleEntry,
|
|
277
|
+
type ScheduleProvider,
|
|
278
|
+
type ScheduleRunRecord,
|
|
279
|
+
type ScheduleRunStore,
|
|
280
|
+
type ScheduleRunTrigger,
|
|
281
|
+
type ScheduleStore,
|
|
282
|
+
} from "./schedule.ts";
|
|
257
283
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
258
284
|
export type {
|
|
259
285
|
ApprovalStatus,
|