fullstackgtm 0.23.2 → 0.25.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 +57 -0
- package/README.md +25 -1
- 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/llms.txt +20 -0
- package/package.json +2 -1
- package/skills/fullstackgtm/SKILL.md +87 -0
- package/src/cli.ts +447 -2
- package/src/index.ts +26 -0
- package/src/schedule.ts +609 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The schedule layer (spec: docs/schedule.md in the monorepo): declare once
|
|
3
|
+
* that a fullstackgtm command should run on a cadence, materialize the timers
|
|
4
|
+
* through a provider (MVP: the user crontab), and keep an append-only run
|
|
5
|
+
* history. Horizontal on purpose — no feature namespace owns cron logic.
|
|
6
|
+
*
|
|
7
|
+
* The governance invariant: **scheduling never auto-approves.** Schedulable
|
|
8
|
+
* commands are read/plan-side; `apply` is schedulable only as
|
|
9
|
+
* `apply --plan-id <id>` and the plan's approved status is re-checked at
|
|
10
|
+
* every firing. Unattended runs accumulate proposals, never surprise writes.
|
|
11
|
+
*/
|
|
12
|
+
export type ScheduleProvider = "local";
|
|
13
|
+
export type ScheduleEntry = {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Fullstackgtm command argv (no binary name, no --profile) — never shell. */
|
|
17
|
+
argv: string[];
|
|
18
|
+
/** 5-field cron expression (minute hour day-of-month month day-of-week). */
|
|
19
|
+
cron: string;
|
|
20
|
+
provider: ScheduleProvider;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
};
|
|
24
|
+
export type ScheduleRunTrigger = "cron" | "manual";
|
|
25
|
+
export type ScheduleRunRecord = {
|
|
26
|
+
scheduleId: string;
|
|
27
|
+
firedAt: string;
|
|
28
|
+
completedAt: string;
|
|
29
|
+
trigger: ScheduleRunTrigger;
|
|
30
|
+
exitCode: number;
|
|
31
|
+
/** Last ~50 lines of the command's stdout+stderr. */
|
|
32
|
+
outputTail: string;
|
|
33
|
+
/** Plan ids / enrich run labels the firing produced (store-diffed). */
|
|
34
|
+
artifacts: {
|
|
35
|
+
planIds: string[];
|
|
36
|
+
runLabels: string[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Set when a governance gate refused to execute the command: the firing is
|
|
40
|
+
* recorded for visibility but nothing ran. Reasons: "plan_not_approved"
|
|
41
|
+
* (scheduled apply against a plan that is not approved — there is no flag
|
|
42
|
+
* that relaxes this), "not_schedulable" (the stored argv no longer passes
|
|
43
|
+
* the allowlist, e.g. after a hand edit of schedules.json).
|
|
44
|
+
*/
|
|
45
|
+
noopReason?: "plan_not_approved" | "not_schedulable";
|
|
46
|
+
};
|
|
47
|
+
export declare function scheduleId(label: string, cron: string, argv: string[], createdAt: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Validate that an argv resolves to a schedulable fullstackgtm command.
|
|
50
|
+
* Enforced at `schedule add` time AND re-checked at `schedule run` time (the
|
|
51
|
+
* store is a user-editable JSON file). Throws with the full allowlist on
|
|
52
|
+
* rejection; arbitrary shell is structurally impossible anyway (runs dispatch
|
|
53
|
+
* in-process into the CLI router, never through a shell).
|
|
54
|
+
*/
|
|
55
|
+
export declare function validateSchedulableArgv(argv: string[]): void;
|
|
56
|
+
/**
|
|
57
|
+
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
58
|
+
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
59
|
+
*/
|
|
60
|
+
export declare function tokenizeCommand(text: string): string[];
|
|
61
|
+
export type CronExpression = {
|
|
62
|
+
source: string;
|
|
63
|
+
minute: number[];
|
|
64
|
+
hour: number[];
|
|
65
|
+
dayOfMonth: number[];
|
|
66
|
+
month: number[];
|
|
67
|
+
/** 0–6, Sunday = 0 (an input 7 normalizes to 0). */
|
|
68
|
+
dayOfWeek: number[];
|
|
69
|
+
/** Vixie-cron day semantics: when BOTH are restricted, either may match. */
|
|
70
|
+
dayOfMonthRestricted: boolean;
|
|
71
|
+
dayOfWeekRestricted: boolean;
|
|
72
|
+
};
|
|
73
|
+
export declare function parseCron(expression: string): CronExpression;
|
|
74
|
+
/** True when the expression fires in `date`'s minute (local time, like cron). */
|
|
75
|
+
export declare function cronMatches(cron: CronExpression, date: Date): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* The next firing strictly after `after` (local time). Throws when the
|
|
78
|
+
* expression never fires (e.g. "0 0 31 2 *") — `schedule add` calls this once
|
|
79
|
+
* so impossible expressions are rejected up front with a clear error.
|
|
80
|
+
*/
|
|
81
|
+
export declare function nextCronFiring(cron: CronExpression, after: Date): Date;
|
|
82
|
+
/**
|
|
83
|
+
* Expected firings strictly inside (fromExclusive, toExclusive), capped — the
|
|
84
|
+
* input to missed-firing detection, never to catch-up execution.
|
|
85
|
+
*/
|
|
86
|
+
export declare function expectedFirings(cron: CronExpression, fromExclusive: Date, toExclusive: Date, cap?: number): Date[];
|
|
87
|
+
/**
|
|
88
|
+
* Missed firings since the last run record (or the entry's creation when no
|
|
89
|
+
* run has ever fired): expected firings with no run record in their minute.
|
|
90
|
+
* Local cron has no catch-up — a laptop asleep at firing time skips the run —
|
|
91
|
+
* so this is visibility only; nothing is re-executed.
|
|
92
|
+
*/
|
|
93
|
+
export declare function computeMissedFirings(entry: Pick<ScheduleEntry, "cron" | "createdAt">, runs: ScheduleRunRecord[], now?: Date, cap?: number): {
|
|
94
|
+
missed: Date[];
|
|
95
|
+
capped: boolean;
|
|
96
|
+
};
|
|
97
|
+
export declare function schedulesPath(baseDir?: string): string;
|
|
98
|
+
export declare function scheduleRunsDir(baseDir?: string): string;
|
|
99
|
+
export interface ScheduleStore {
|
|
100
|
+
add(entry: ScheduleEntry): Promise<ScheduleEntry>;
|
|
101
|
+
list(): Promise<ScheduleEntry[]>;
|
|
102
|
+
get(id: string): Promise<ScheduleEntry | null>;
|
|
103
|
+
remove(id: string): Promise<boolean>;
|
|
104
|
+
setEnabled(id: string, enabled: boolean): Promise<ScheduleEntry>;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Schedule entries as one small JSON file per profile, sibling to plans and
|
|
108
|
+
* enrich runs. `directory` overrides the profile home (tests).
|
|
109
|
+
*/
|
|
110
|
+
export declare function createFileScheduleStore(directory?: string): ScheduleStore;
|
|
111
|
+
export interface ScheduleRunStore {
|
|
112
|
+
record(run: ScheduleRunRecord): Promise<ScheduleRunRecord>;
|
|
113
|
+
/** Run records for one schedule, oldest first; `limit` keeps the newest N. */
|
|
114
|
+
list(scheduleId: string, limit?: number): Promise<ScheduleRunRecord[]>;
|
|
115
|
+
}
|
|
116
|
+
export declare function createFileScheduleRunStore(directory?: string): ScheduleRunStore;
|
|
117
|
+
export type CrontabIo = {
|
|
118
|
+
/** Current crontab content; "" when the user has none. */
|
|
119
|
+
read(): string;
|
|
120
|
+
write(content: string): void;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The real user crontab via `crontab -l` / `crontab -`. Everything above this
|
|
124
|
+
* function takes a CrontabIo so tests inject fakes and never touch it.
|
|
125
|
+
*/
|
|
126
|
+
export declare function systemCrontabIo(): CrontabIo;
|
|
127
|
+
export declare function crontabSentinels(profile: string): {
|
|
128
|
+
open: string;
|
|
129
|
+
close: string;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Render the managed block for one profile. Every line invokes
|
|
133
|
+
* `... schedule run <id> --profile <profile> --trigger cron` — the single
|
|
134
|
+
* provider entry point — so the crontab a user audits never contains anything
|
|
135
|
+
* but fullstackgtm dispatch (no arbitrary shell, ever).
|
|
136
|
+
*/
|
|
137
|
+
export declare function renderManagedBlock(profile: string, entries: ScheduleEntry[], cliInvocation: string): string;
|
|
138
|
+
/**
|
|
139
|
+
* Replace (or with `block: null`, remove) the profile's managed block in the
|
|
140
|
+
* crontab content, byte-for-byte preserving everything outside the sentinels.
|
|
141
|
+
* Idempotent: re-applying the same block yields identical content.
|
|
142
|
+
*/
|
|
143
|
+
export declare function replaceManagedBlock(existing: string, profile: string, block: string | null): string;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
5
|
+
// Mirrors stableHash in rules.ts (FNV-1a); duplicated to keep schedule.ts
|
|
6
|
+
// importable without pulling the audit engine (the market/enrich precedent).
|
|
7
|
+
function fnv1a(value) {
|
|
8
|
+
let hash = 0x811c9dc5;
|
|
9
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
10
|
+
hash ^= value.charCodeAt(i);
|
|
11
|
+
hash = Math.imul(hash, 0x01000193);
|
|
12
|
+
}
|
|
13
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
14
|
+
}
|
|
15
|
+
export function scheduleId(label, cron, argv, createdAt) {
|
|
16
|
+
return `sch_${fnv1a(`${label}|${cron}|${argv.join(" ")}|${createdAt}`)}`;
|
|
17
|
+
}
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Governance: the schedulable allowlist
|
|
20
|
+
/**
|
|
21
|
+
* Read/plan-side commands only. Their unattended output is plans in the
|
|
22
|
+
* queue, run records, and reports — never CRM writes. `apply` is the single
|
|
23
|
+
* exception, allowed only as `apply --plan-id <id>` (the plan store's
|
|
24
|
+
* approval status is re-checked at run time; see docs/schedule.md).
|
|
25
|
+
*/
|
|
26
|
+
const SCHEDULABLE = {
|
|
27
|
+
audit: null,
|
|
28
|
+
snapshot: null,
|
|
29
|
+
suggest: null,
|
|
30
|
+
report: null,
|
|
31
|
+
doctor: null,
|
|
32
|
+
enrich: ["append", "refresh"],
|
|
33
|
+
market: ["capture", "refresh"],
|
|
34
|
+
};
|
|
35
|
+
const ALLOWLIST_SUMMARY = "audit, snapshot, enrich append|refresh, market capture|refresh, suggest, report, doctor — " +
|
|
36
|
+
"plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
37
|
+
/**
|
|
38
|
+
* Validate that an argv resolves to a schedulable fullstackgtm command.
|
|
39
|
+
* Enforced at `schedule add` time AND re-checked at `schedule run` time (the
|
|
40
|
+
* store is a user-editable JSON file). Throws with the full allowlist on
|
|
41
|
+
* rejection; arbitrary shell is structurally impossible anyway (runs dispatch
|
|
42
|
+
* in-process into the CLI router, never through a shell).
|
|
43
|
+
*/
|
|
44
|
+
export function validateSchedulableArgv(argv) {
|
|
45
|
+
const head = argv[0];
|
|
46
|
+
if (!head)
|
|
47
|
+
throw new Error("Nothing to schedule: the command is empty.");
|
|
48
|
+
if (argv.includes("--profile")) {
|
|
49
|
+
throw new Error("Do not embed --profile in the scheduled command — schedules are already profile-scoped. " +
|
|
50
|
+
"Pass --profile <name> to `schedule add` itself instead.");
|
|
51
|
+
}
|
|
52
|
+
if (head === "apply") {
|
|
53
|
+
const planIdIndex = argv.indexOf("--plan-id");
|
|
54
|
+
const planId = planIdIndex === -1 ? undefined : argv[planIdIndex + 1];
|
|
55
|
+
if (!planId || planId.startsWith("--")) {
|
|
56
|
+
throw new Error("apply is schedulable ONLY as `apply --plan-id <id> ...`: the plan must already exist in " +
|
|
57
|
+
"the plan store, and its status is re-checked as approved at every firing. " +
|
|
58
|
+
"Scheduling never auto-approves.");
|
|
59
|
+
}
|
|
60
|
+
if (argv.includes("--plan") || argv.includes("--approve")) {
|
|
61
|
+
throw new Error("A scheduled apply cannot take --plan/--approve — file-based approval would bypass the " +
|
|
62
|
+
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.");
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
|
67
|
+
throw new Error(`"${head}" is not schedulable. Schedulable commands are read/plan-side only: ${ALLOWLIST_SUMMARY}. ` +
|
|
68
|
+
"Unattended runs accumulate proposals, never writes.");
|
|
69
|
+
}
|
|
70
|
+
const subcommands = SCHEDULABLE[head];
|
|
71
|
+
if (subcommands) {
|
|
72
|
+
const sub = argv[1];
|
|
73
|
+
if (!sub || !subcommands.includes(sub)) {
|
|
74
|
+
throw new Error(`"${head}${sub ? ` ${sub}` : ""}" is not schedulable — only: ${subcommands
|
|
75
|
+
.map((name) => `${head} ${name}`)
|
|
76
|
+
.join(", ")}.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
82
|
+
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
83
|
+
*/
|
|
84
|
+
export function tokenizeCommand(text) {
|
|
85
|
+
const tokens = [];
|
|
86
|
+
let current = "";
|
|
87
|
+
let quote = null;
|
|
88
|
+
let inToken = false;
|
|
89
|
+
for (const char of text) {
|
|
90
|
+
if (quote) {
|
|
91
|
+
if (char === quote)
|
|
92
|
+
quote = null;
|
|
93
|
+
else
|
|
94
|
+
current += char;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (char === '"' || char === "'") {
|
|
98
|
+
quote = char;
|
|
99
|
+
inToken = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (/\s/.test(char)) {
|
|
103
|
+
if (inToken) {
|
|
104
|
+
tokens.push(current);
|
|
105
|
+
current = "";
|
|
106
|
+
inToken = false;
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
current += char;
|
|
111
|
+
inToken = true;
|
|
112
|
+
}
|
|
113
|
+
if (quote)
|
|
114
|
+
throw new Error(`Unclosed ${quote} quote in the scheduled command.`);
|
|
115
|
+
if (inToken)
|
|
116
|
+
tokens.push(current);
|
|
117
|
+
return tokens;
|
|
118
|
+
}
|
|
119
|
+
const CRON_FIELD_SPECS = [
|
|
120
|
+
{ name: "minute", min: 0, max: 59 },
|
|
121
|
+
{ name: "hour", min: 0, max: 23 },
|
|
122
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
123
|
+
{ name: "month", min: 1, max: 12 },
|
|
124
|
+
{ name: "day-of-week", min: 0, max: 7 },
|
|
125
|
+
];
|
|
126
|
+
export function parseCron(expression) {
|
|
127
|
+
const fields = expression.trim().split(/\s+/);
|
|
128
|
+
if (fields.length !== 5) {
|
|
129
|
+
throw new Error(`Invalid cron expression "${expression}": expected 5 fields ` +
|
|
130
|
+
`(minute hour day-of-month month day-of-week), got ${fields.length}.`);
|
|
131
|
+
}
|
|
132
|
+
const [minute, hour, dayOfMonth, month, dayOfWeekRaw] = fields.map((field, index) => parseCronField(field, CRON_FIELD_SPECS[index], expression));
|
|
133
|
+
// 7 is an accepted alias for Sunday; normalize so matching uses 0–6.
|
|
134
|
+
const dayOfWeek = Array.from(new Set(dayOfWeekRaw.map((value) => (value === 7 ? 0 : value)))).sort((a, b) => a - b);
|
|
135
|
+
return {
|
|
136
|
+
source: expression.trim(),
|
|
137
|
+
minute,
|
|
138
|
+
hour,
|
|
139
|
+
dayOfMonth,
|
|
140
|
+
month,
|
|
141
|
+
dayOfWeek,
|
|
142
|
+
dayOfMonthRestricted: fields[2] !== "*",
|
|
143
|
+
dayOfWeekRestricted: fields[4] !== "*",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function parseCronField(field, spec, expression) {
|
|
147
|
+
const values = new Set();
|
|
148
|
+
const fail = (problem) => {
|
|
149
|
+
throw new Error(`Invalid cron expression "${expression}": ${spec.name} field "${field}" ${problem}.`);
|
|
150
|
+
};
|
|
151
|
+
if (field === "")
|
|
152
|
+
fail("is empty");
|
|
153
|
+
for (const part of field.split(",")) {
|
|
154
|
+
const slashed = part.split("/");
|
|
155
|
+
if (slashed.length > 2)
|
|
156
|
+
fail(`has more than one "/" in "${part}"`);
|
|
157
|
+
const [rangeText, stepText] = slashed;
|
|
158
|
+
let step = 1;
|
|
159
|
+
if (stepText !== undefined) {
|
|
160
|
+
if (!/^\d+$/.test(stepText) || Number(stepText) < 1) {
|
|
161
|
+
fail(`has an invalid step "/${stepText}" (steps are positive integers)`);
|
|
162
|
+
}
|
|
163
|
+
step = Number(stepText);
|
|
164
|
+
}
|
|
165
|
+
let low;
|
|
166
|
+
let high;
|
|
167
|
+
if (rangeText === "*") {
|
|
168
|
+
low = spec.min;
|
|
169
|
+
high = spec.max;
|
|
170
|
+
}
|
|
171
|
+
else if (/^\d+$/.test(rangeText)) {
|
|
172
|
+
low = Number(rangeText);
|
|
173
|
+
// Vixie semantics: "N/step" means N through the field max, stepped.
|
|
174
|
+
high = stepText !== undefined ? spec.max : low;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const range = /^(\d+)-(\d+)$/.exec(rangeText);
|
|
178
|
+
if (!range)
|
|
179
|
+
return fail(`has an unsupported token "${part}" (use *, numbers, lists, ranges, steps)`);
|
|
180
|
+
low = Number(range[1]);
|
|
181
|
+
high = Number(range[2]);
|
|
182
|
+
if (low > high)
|
|
183
|
+
fail(`has a range "${rangeText}" that starts after it ends`);
|
|
184
|
+
}
|
|
185
|
+
if (low < spec.min || high > spec.max) {
|
|
186
|
+
fail(`is out of range (${spec.min}–${spec.max})`);
|
|
187
|
+
}
|
|
188
|
+
for (let value = low; value <= high; value += step)
|
|
189
|
+
values.add(value);
|
|
190
|
+
}
|
|
191
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
192
|
+
}
|
|
193
|
+
function cronDayMatches(cron, date) {
|
|
194
|
+
if (!cron.month.includes(date.getMonth() + 1))
|
|
195
|
+
return false;
|
|
196
|
+
const domMatch = cron.dayOfMonth.includes(date.getDate());
|
|
197
|
+
const dowMatch = cron.dayOfWeek.includes(date.getDay());
|
|
198
|
+
if (cron.dayOfMonthRestricted && cron.dayOfWeekRestricted)
|
|
199
|
+
return domMatch || dowMatch;
|
|
200
|
+
if (cron.dayOfMonthRestricted)
|
|
201
|
+
return domMatch;
|
|
202
|
+
if (cron.dayOfWeekRestricted)
|
|
203
|
+
return dowMatch;
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
/** True when the expression fires in `date`'s minute (local time, like cron). */
|
|
207
|
+
export function cronMatches(cron, date) {
|
|
208
|
+
return (cron.minute.includes(date.getMinutes()) &&
|
|
209
|
+
cron.hour.includes(date.getHours()) &&
|
|
210
|
+
cronDayMatches(cron, date));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The next firing strictly after `after` (local time). Throws when the
|
|
214
|
+
* expression never fires (e.g. "0 0 31 2 *") — `schedule add` calls this once
|
|
215
|
+
* so impossible expressions are rejected up front with a clear error.
|
|
216
|
+
*/
|
|
217
|
+
export function nextCronFiring(cron, after) {
|
|
218
|
+
const start = new Date(after.getTime());
|
|
219
|
+
start.setSeconds(0, 0);
|
|
220
|
+
start.setMinutes(start.getMinutes() + 1);
|
|
221
|
+
// Day-level iteration; 4 years + 1 day covers schedules that only fire on
|
|
222
|
+
// a leap day. Hour/minute lists are sorted, so the first hit is the answer.
|
|
223
|
+
for (let dayOffset = 0; dayOffset <= 1462; dayOffset += 1) {
|
|
224
|
+
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate() + dayOffset);
|
|
225
|
+
if (!cronDayMatches(cron, day))
|
|
226
|
+
continue;
|
|
227
|
+
const sameDay = dayOffset === 0;
|
|
228
|
+
for (const hour of cron.hour) {
|
|
229
|
+
if (sameDay && hour < start.getHours())
|
|
230
|
+
continue;
|
|
231
|
+
for (const minute of cron.minute) {
|
|
232
|
+
if (sameDay && hour === start.getHours() && minute < start.getMinutes())
|
|
233
|
+
continue;
|
|
234
|
+
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw new Error(`Cron expression "${cron.source}" never fires (no matching date within 4 years).`);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Expected firings strictly inside (fromExclusive, toExclusive), capped — the
|
|
242
|
+
* input to missed-firing detection, never to catch-up execution.
|
|
243
|
+
*/
|
|
244
|
+
export function expectedFirings(cron, fromExclusive, toExclusive, cap = 100) {
|
|
245
|
+
const firings = [];
|
|
246
|
+
let cursor = fromExclusive;
|
|
247
|
+
while (firings.length < cap) {
|
|
248
|
+
let next;
|
|
249
|
+
try {
|
|
250
|
+
next = nextCronFiring(cron, cursor);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
break; // an expression that never fires has nothing to miss
|
|
254
|
+
}
|
|
255
|
+
if (next.getTime() >= toExclusive.getTime())
|
|
256
|
+
break;
|
|
257
|
+
firings.push(next);
|
|
258
|
+
cursor = next;
|
|
259
|
+
}
|
|
260
|
+
return firings;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Missed firings since the last run record (or the entry's creation when no
|
|
264
|
+
* run has ever fired): expected firings with no run record in their minute.
|
|
265
|
+
* Local cron has no catch-up — a laptop asleep at firing time skips the run —
|
|
266
|
+
* so this is visibility only; nothing is re-executed.
|
|
267
|
+
*/
|
|
268
|
+
export function computeMissedFirings(entry, runs, now = new Date(), cap = 100) {
|
|
269
|
+
const cron = parseCron(entry.cron);
|
|
270
|
+
const lastRun = runs.length > 0 ? runs[runs.length - 1] : null;
|
|
271
|
+
const baseline = new Date(lastRun ? lastRun.firedAt : entry.createdAt);
|
|
272
|
+
const expected = expectedFirings(cron, baseline, now, cap);
|
|
273
|
+
const firedMinutes = new Set(runs.map((run) => {
|
|
274
|
+
const fired = new Date(run.firedAt);
|
|
275
|
+
fired.setSeconds(0, 0);
|
|
276
|
+
return fired.getTime();
|
|
277
|
+
}));
|
|
278
|
+
const missed = expected.filter((firing) => !firedMinutes.has(firing.getTime()));
|
|
279
|
+
return { missed, capped: expected.length >= cap };
|
|
280
|
+
}
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Stores: schedules.json + schedule/runs/<id>/<timestamp>.json
|
|
283
|
+
export function schedulesPath(baseDir) {
|
|
284
|
+
return join(baseDir ?? credentialsDir(), "schedules.json");
|
|
285
|
+
}
|
|
286
|
+
export function scheduleRunsDir(baseDir) {
|
|
287
|
+
return join(baseDir ?? credentialsDir(), "schedule", "runs");
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Schedule entries as one small JSON file per profile, sibling to plans and
|
|
291
|
+
* enrich runs. `directory` overrides the profile home (tests).
|
|
292
|
+
*/
|
|
293
|
+
export function createFileScheduleStore(directory) {
|
|
294
|
+
const path = () => schedulesPath(directory);
|
|
295
|
+
function read() {
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(readFileSync(path(), "utf8"));
|
|
298
|
+
if (parsed && typeof parsed === "object" && parsed.version === 1 && Array.isArray(parsed.schedules)) {
|
|
299
|
+
return parsed;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// Missing or unreadable file falls through to an empty store.
|
|
304
|
+
}
|
|
305
|
+
return { version: 1, schedules: [] };
|
|
306
|
+
}
|
|
307
|
+
function write(file) {
|
|
308
|
+
if (directory)
|
|
309
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
310
|
+
else
|
|
311
|
+
ensureSecureHomeDir();
|
|
312
|
+
writeSecureFile(path(), `${JSON.stringify(file, null, 2)}\n`);
|
|
313
|
+
}
|
|
314
|
+
function mustFind(file, id) {
|
|
315
|
+
const entry = file.schedules.find((candidate) => candidate.id === id);
|
|
316
|
+
if (!entry)
|
|
317
|
+
throw new Error(`No schedule with id ${id}. List entries with \`fullstackgtm schedule list\`.`);
|
|
318
|
+
return entry;
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
async add(entry) {
|
|
322
|
+
const file = read();
|
|
323
|
+
if (file.schedules.some((candidate) => candidate.id === entry.id)) {
|
|
324
|
+
throw new Error(`Schedule ${entry.id} already exists.`);
|
|
325
|
+
}
|
|
326
|
+
file.schedules.push(entry);
|
|
327
|
+
write(file);
|
|
328
|
+
return entry;
|
|
329
|
+
},
|
|
330
|
+
async list() {
|
|
331
|
+
return read().schedules;
|
|
332
|
+
},
|
|
333
|
+
async get(id) {
|
|
334
|
+
return read().schedules.find((candidate) => candidate.id === id) ?? null;
|
|
335
|
+
},
|
|
336
|
+
async remove(id) {
|
|
337
|
+
const file = read();
|
|
338
|
+
const remaining = file.schedules.filter((candidate) => candidate.id !== id);
|
|
339
|
+
if (remaining.length === file.schedules.length)
|
|
340
|
+
return false;
|
|
341
|
+
write({ ...file, schedules: remaining });
|
|
342
|
+
return true;
|
|
343
|
+
},
|
|
344
|
+
async setEnabled(id, enabled) {
|
|
345
|
+
const file = read();
|
|
346
|
+
const entry = mustFind(file, id);
|
|
347
|
+
entry.enabled = enabled;
|
|
348
|
+
write(file);
|
|
349
|
+
return entry;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
export function createFileScheduleRunStore(directory) {
|
|
354
|
+
const baseDir = () => directory ?? scheduleRunsDir();
|
|
355
|
+
function dirFor(scheduleId) {
|
|
356
|
+
if (!/^[\w.-]+$/.test(scheduleId))
|
|
357
|
+
throw new Error(`Invalid schedule id: ${scheduleId}`);
|
|
358
|
+
return join(baseDir(), scheduleId);
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
async record(run) {
|
|
362
|
+
const dir = dirFor(run.scheduleId);
|
|
363
|
+
if (!directory)
|
|
364
|
+
ensureSecureHomeDir();
|
|
365
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
366
|
+
// ISO timestamps carry ":" — sanitize for the filename, keep the record verbatim.
|
|
367
|
+
const fileName = `${run.firedAt.replace(/[:.]/g, "-")}.json`;
|
|
368
|
+
writeSecureFile(join(dir, fileName), `${JSON.stringify(run, null, 2)}\n`);
|
|
369
|
+
return run;
|
|
370
|
+
},
|
|
371
|
+
async list(scheduleId, limit) {
|
|
372
|
+
let names = [];
|
|
373
|
+
try {
|
|
374
|
+
names = readdirSync(dirFor(scheduleId)).filter((name) => name.endsWith(".json"));
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
const runs = names
|
|
380
|
+
.sort()
|
|
381
|
+
.map((name) => {
|
|
382
|
+
try {
|
|
383
|
+
return JSON.parse(readFileSync(join(dirFor(scheduleId), name), "utf8"));
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
.filter((run) => run !== null);
|
|
390
|
+
return limit !== undefined && limit >= 0 ? runs.slice(-limit) : runs;
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The real user crontab via `crontab -l` / `crontab -`. Everything above this
|
|
396
|
+
* function takes a CrontabIo so tests inject fakes and never touch it.
|
|
397
|
+
*/
|
|
398
|
+
export function systemCrontabIo() {
|
|
399
|
+
return {
|
|
400
|
+
read() {
|
|
401
|
+
const result = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
402
|
+
if (result.error)
|
|
403
|
+
throw new Error(`Cannot run \`crontab\`: ${result.error.message}`);
|
|
404
|
+
if (result.status !== 0) {
|
|
405
|
+
// "no crontab for <user>" exits 1: an empty crontab, not a failure.
|
|
406
|
+
if (/no crontab/i.test(result.stderr ?? ""))
|
|
407
|
+
return "";
|
|
408
|
+
throw new Error(`\`crontab -l\` failed: ${(result.stderr ?? "").trim()}`);
|
|
409
|
+
}
|
|
410
|
+
return result.stdout ?? "";
|
|
411
|
+
},
|
|
412
|
+
write(content) {
|
|
413
|
+
const result = spawnSync("crontab", ["-"], { input: content, encoding: "utf8" });
|
|
414
|
+
if (result.error)
|
|
415
|
+
throw new Error(`Cannot run \`crontab\`: ${result.error.message}`);
|
|
416
|
+
if (result.status !== 0) {
|
|
417
|
+
throw new Error(`crontab rejected the rendered block: ${(result.stderr ?? "").trim()}`);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
export function crontabSentinels(profile) {
|
|
423
|
+
return {
|
|
424
|
+
open: `# >>> fullstackgtm ${profile} >>>`,
|
|
425
|
+
close: `# <<< fullstackgtm ${profile} <<<`,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Render the managed block for one profile. Every line invokes
|
|
430
|
+
* `... schedule run <id> --profile <profile> --trigger cron` — the single
|
|
431
|
+
* provider entry point — so the crontab a user audits never contains anything
|
|
432
|
+
* but fullstackgtm dispatch (no arbitrary shell, ever).
|
|
433
|
+
*/
|
|
434
|
+
export function renderManagedBlock(profile, entries, cliInvocation) {
|
|
435
|
+
const { open, close } = crontabSentinels(profile);
|
|
436
|
+
const lines = [
|
|
437
|
+
open,
|
|
438
|
+
"# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
|
|
439
|
+
];
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
lines.push(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`);
|
|
442
|
+
lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
|
|
443
|
+
}
|
|
444
|
+
lines.push(close);
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Replace (or with `block: null`, remove) the profile's managed block in the
|
|
449
|
+
* crontab content, byte-for-byte preserving everything outside the sentinels.
|
|
450
|
+
* Idempotent: re-applying the same block yields identical content.
|
|
451
|
+
*/
|
|
452
|
+
export function replaceManagedBlock(existing, profile, block) {
|
|
453
|
+
const { open, close } = crontabSentinels(profile);
|
|
454
|
+
const lines = existing.split("\n");
|
|
455
|
+
const start = lines.indexOf(open);
|
|
456
|
+
const end = lines.indexOf(close);
|
|
457
|
+
let before;
|
|
458
|
+
let after;
|
|
459
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
460
|
+
before = lines.slice(0, start);
|
|
461
|
+
after = lines.slice(end + 1);
|
|
462
|
+
}
|
|
463
|
+
else if (start !== -1 || end !== -1) {
|
|
464
|
+
throw new Error(`The crontab contains a damaged fullstackgtm block for profile "${profile}" ` +
|
|
465
|
+
"(one sentinel without its pair). Repair it by hand, then re-run install.");
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
before = lines;
|
|
469
|
+
after = [];
|
|
470
|
+
}
|
|
471
|
+
const merged = [...before];
|
|
472
|
+
if (block !== null) {
|
|
473
|
+
if (merged.length > 0 && merged[merged.length - 1].trim() !== "")
|
|
474
|
+
merged.push("");
|
|
475
|
+
merged.push(...block.split("\n"));
|
|
476
|
+
}
|
|
477
|
+
merged.push(...after);
|
|
478
|
+
while (merged.length > 0 && merged[0].trim() === "")
|
|
479
|
+
merged.shift();
|
|
480
|
+
while (merged.length > 0 && merged[merged.length - 1].trim() === "")
|
|
481
|
+
merged.pop();
|
|
482
|
+
if (merged.length === 0)
|
|
483
|
+
return "";
|
|
484
|
+
return `${merged.join("\n")}\n`;
|
|
485
|
+
}
|
package/llms.txt
CHANGED
|
@@ -14,6 +14,7 @@ at/above `--fail-on`.
|
|
|
14
14
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
|
+
- [Agent skill](https://github.com/fullstackgtm/core/blob/main/skills/fullstackgtm/SKILL.md): compact operating guide, installable via `npx skills add fullstackgtm/core`
|
|
17
18
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
18
19
|
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
19
20
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
@@ -87,6 +88,25 @@ CAS). Conflict policy MVP is `never`; `system-only`/`always` are phase 2 and
|
|
|
87
88
|
refused explicitly. Run store (checkpoint + staleness ledger + `status`) is
|
|
88
89
|
profile-scoped under `<home>/enrich/runs`. No cron — scheduling is horizontal.
|
|
89
90
|
|
|
91
|
+
## Key invariants (schedule)
|
|
92
|
+
|
|
93
|
+
`fullstackgtm schedule` is the horizontal scheduler — no feature namespace
|
|
94
|
+
owns cron logic. `add "<command>" --cron "<expr>"` validates the command
|
|
95
|
+
against the read/plan-side allowlist (audit, snapshot, enrich append|refresh,
|
|
96
|
+
market capture|refresh, suggest, report, doctor) and the 5-field cron
|
|
97
|
+
expression, but touches no crontab; `install` materializes enabled entries
|
|
98
|
+
into a sentinel-delimited managed block (`# >>> fullstackgtm <profile> >>>`)
|
|
99
|
+
replaced wholesale on re-install, lines outside it untouched; `uninstall`
|
|
100
|
+
removes only the block. Scheduling never auto-approves: `apply` is
|
|
101
|
+
schedulable ONLY as `apply --plan-id <id>` and every firing re-checks the
|
|
102
|
+
plan is approved — unapproved plans record a `plan_not_approved` no-op run,
|
|
103
|
+
no flag relaxes this. `schedule run <id>` is the single provider entry point
|
|
104
|
+
(in-process dispatch, never shell); run records (exit code, output tail,
|
|
105
|
+
artifacts: plan ids / enrich run labels, trigger cron|manual) land under
|
|
106
|
+
`<home>/schedule/runs/<id>/`. `status` shows next firing and surfaces missed
|
|
107
|
+
firings (visibility only — local cron has no catch-up). Providers beyond
|
|
108
|
+
`local` are not yet implemented (future: codegen scaffolds, same contract).
|
|
109
|
+
|
|
90
110
|
## Key invariants
|
|
91
111
|
|
|
92
112
|
- 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.25.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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"CHANGELOG.md",
|
|
31
31
|
"INSTALL_FOR_AGENTS.md",
|
|
32
32
|
"llms.txt",
|
|
33
|
+
"skills",
|
|
33
34
|
"LICENSE"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|