granttap-mcp 0.8.115 → 0.8.116
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/THIRD_PARTY_LICENSES.txt +30 -0
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/apps/bridge/src/host/session-keys.ts +2 -1
- package/apps/bridge/src/monitor/handlers/inbound.ts +2 -0
- package/apps/bridge/src/monitor/support/heartbeat.ts +4 -2
- package/apps/bridge/src/providers/grok-catalog-cache/README.md +8 -0
- package/apps/bridge/src/providers/grok-catalog-cache/index.ts +74 -0
- package/apps/bridge/src/sessions/scan/grok.ts +30 -22
- package/apps/bridge/src/task-schedules/README.md +23 -0
- package/apps/bridge/src/task-schedules/cadence/README.md +9 -0
- package/apps/bridge/src/task-schedules/cadence/index.ts +43 -0
- package/apps/bridge/src/task-schedules/changes.ts +76 -0
- package/apps/bridge/src/task-schedules/controls.ts +63 -0
- package/apps/bridge/src/task-schedules/index.ts +64 -0
- package/apps/bridge/src/task-schedules/native-change.ts +43 -0
- package/apps/bridge/src/task-schedules/native-state.ts +63 -0
- package/apps/bridge/src/task-schedules/relay.ts +41 -0
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/package.json +2 -1
- package/packages/protocol/schema.ts +5 -0
- package/packages/protocol/task-schedules/README.md +8 -0
- package/packages/protocol/task-schedules/index.ts +32 -0
package/THIRD_PARTY_LICENSES.txt
CHANGED
|
@@ -2737,3 +2737,33 @@ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
|
2737
2737
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
2738
2738
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
2739
2739
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
smol-toml 1.9.1 — BSD-3-Clause
|
|
2743
|
+
Source: https://www.npmjs.com/package/smol-toml/v/1.9.1
|
|
2744
|
+
Notices: fa5659948374d4f555594f47f6da073b40dc503e921aeeece30df4362b3051a5
|
|
2745
|
+
|
|
2746
|
+
Copyright (c) Squirrel Chat et al., All rights reserved.
|
|
2747
|
+
|
|
2748
|
+
Redistribution and use in source and binary forms, with or without
|
|
2749
|
+
modification, are permitted provided that the following conditions are met:
|
|
2750
|
+
|
|
2751
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
2752
|
+
list of conditions and the following disclaimer.
|
|
2753
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
2754
|
+
this list of conditions and the following disclaimer in the
|
|
2755
|
+
documentation and/or other materials provided with the distribution.
|
|
2756
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
2757
|
+
may be used to endorse or promote products derived from this software without
|
|
2758
|
+
specific prior written permission.
|
|
2759
|
+
|
|
2760
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
2761
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
2762
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
2763
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
2764
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
2765
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
2766
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
2767
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
2768
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
2769
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -20,6 +20,7 @@ boundaries and upstream grants remain separate from GrantTap's first-party comme
|
|
|
20
20
|
| `tweetnacl` | The Unlicense |
|
|
21
21
|
| `ws` | MIT |
|
|
22
22
|
| `zod` | MIT |
|
|
23
|
+
| `smol-toml` | BSD-3-Clause |
|
|
23
24
|
|
|
24
25
|
The unpublished Rust workspace also depends on separately licensed components,
|
|
25
26
|
including `mcport` (MIT), `blazingly-json` (MIT), `serde` (MIT OR Apache-2.0), and
|
|
@@ -83,6 +83,7 @@ async function sendScopedPayload(input: {
|
|
|
83
83
|
key,
|
|
84
84
|
purpose,
|
|
85
85
|
createdAt: Date.now(),
|
|
86
|
-
}, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey,
|
|
86
|
+
}, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey,
|
|
87
|
+
...(options.urgent ? { urgent: true } : {}), ...grantOptions });
|
|
87
88
|
await client.sendSession(payload, sessionId, to, options);
|
|
88
89
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { handleTaskTransferCommand } from "../../task-transfer/runtime";
|
|
2
2
|
import { handleDeviceManagement } from '../../device-management';
|
|
3
|
+
import { handleTaskSchedules } from '../../task-schedules/relay';
|
|
3
4
|
import { handleProjectTransportConfiguration } from '../../task-network/runtime';
|
|
4
5
|
import { handleHookTrust } from "../../codex-hook-trust";
|
|
5
6
|
import { handleConfigurationCommand } from "../../configuration-delivery";
|
|
@@ -69,6 +70,7 @@ export async function handleMonitorMessage(
|
|
|
69
70
|
if (!leadership.acquire()) return false;
|
|
70
71
|
const configuration = handlePeerConfiguration(client, payload, input.peerPublicKey, publish);
|
|
71
72
|
if (configuration) return configuration;
|
|
73
|
+
if (payload.type === 'task.schedules') return handleTaskSchedules(client, payload, input.peerPublicKey);
|
|
72
74
|
if (payload.type === "knowledge.write") {
|
|
73
75
|
return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
|
|
74
76
|
}
|
|
@@ -20,7 +20,8 @@ export const HEARTBEAT_INTERVAL_MS = Number(
|
|
|
20
20
|
*/
|
|
21
21
|
export async function publishHeartbeat(client: RelayClient): Promise<void> {
|
|
22
22
|
await client.send(
|
|
23
|
-
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true
|
|
23
|
+
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true,
|
|
24
|
+
taskScheduleControls: true },
|
|
24
25
|
"phone",
|
|
25
26
|
{ ttlMs: HEARTBEAT_INTERVAL_MS * 3, reliable: false },
|
|
26
27
|
);
|
|
@@ -38,7 +39,8 @@ export const HELD_HEARTBEAT_TTL_MS = 60_000;
|
|
|
38
39
|
|
|
39
40
|
export async function publishHeldHeartbeat(client: RelayClient): Promise<void> {
|
|
40
41
|
await client.send(
|
|
41
|
-
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true
|
|
42
|
+
{ type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true,
|
|
43
|
+
taskScheduleControls: true },
|
|
42
44
|
"phone",
|
|
43
45
|
{ ttlMs: HELD_HEARTBEAT_TTL_MS, reliable: true },
|
|
44
46
|
);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Grok catalog cache
|
|
2
|
+
|
|
3
|
+
Public entry: `GrokCatalogCache`. Cache only the parsed native inputs belonging
|
|
4
|
+
to the current admitted scan. Model-context files and visible update streams
|
|
5
|
+
have separate fingerprints, so streaming text does not decode an unchanged
|
|
6
|
+
context again. A complete export bypasses this catalog cache.
|
|
7
|
+
|
|
8
|
+
First-party code is under the GrantTap Commercial Source License.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import type { ActivityEntry } from '../../../../../packages/protocol/schema';
|
|
4
|
+
import type { CapabilityObservation } from '../../sessions/telemetry';
|
|
5
|
+
|
|
6
|
+
export type GrokParsedContext = { entries: ActivityEntry[]; usage: CapabilityObservation[];
|
|
7
|
+
firstUser?: string; lastMessageAt?: number; tokens: number; lastTurn: number; cacheable?: boolean };
|
|
8
|
+
type Source = { fingerprint: string; present: boolean };
|
|
9
|
+
type Entry = { contextFingerprint: string; updateFingerprint?: string; context: GrokParsedContext;
|
|
10
|
+
omittedContextEntries: boolean; visible?: ActivityEntry[]; result: GrokParsedContext };
|
|
11
|
+
type Identity = { directory: string; sessionId: string; fallbackTime: number };
|
|
12
|
+
|
|
13
|
+
function source(identity: Identity, names: string[]): Source | undefined {
|
|
14
|
+
let present = false;
|
|
15
|
+
const stamps: string[] = [identity.sessionId, String(identity.fallbackTime)];
|
|
16
|
+
try {
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
try {
|
|
19
|
+
const stat = statSync(join(identity.directory, name), { bigint: true });
|
|
20
|
+
present = true;
|
|
21
|
+
stamps.push([name, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String).join(':'));
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
24
|
+
stamps.push(name + ':missing');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { fingerprint: JSON.stringify(stamps), present };
|
|
28
|
+
} catch { return undefined; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withGrokVisible(context: GrokParsedContext, visible: ActivityEntry[] | undefined): GrokParsedContext {
|
|
32
|
+
if (visible === undefined) return context;
|
|
33
|
+
let firstUser = context.firstUser, lastMessageAt = context.lastMessageAt ?? 0;
|
|
34
|
+
for (const entry of visible) {
|
|
35
|
+
if (entry.kind === 'user') firstUser ??= entry.text.trim().slice(0, 120);
|
|
36
|
+
if (['user', 'message', 'final'].includes(entry.kind)) lastMessageAt = Math.max(lastMessageAt, entry.createdAt);
|
|
37
|
+
}
|
|
38
|
+
return { ...context, entries: visible, firstUser, lastMessageAt: lastMessageAt || undefined };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Retain only the current scan's sources; context and the native visible stream advance independently. */
|
|
42
|
+
export class GrokCatalogCache {
|
|
43
|
+
private readonly entries = new Map<string, Entry>();
|
|
44
|
+
|
|
45
|
+
retain(directories: ReadonlySet<string>): void {
|
|
46
|
+
const keep = new Set([...directories].map(path => resolve(path)));
|
|
47
|
+
for (const key of this.entries.keys()) if (!keep.has(key)) this.entries.delete(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
read(identity: Identity, load: { context: () => GrokParsedContext; visible: () => ActivityEntry[] | undefined }): GrokParsedContext {
|
|
51
|
+
const key = resolve(identity.directory), previous = this.entries.get(key);
|
|
52
|
+
const contextSource = source(identity, ['chat_history.jsonl', 'events.jsonl', 'rewind_points.jsonl']);
|
|
53
|
+
const updateSource = source(identity, ['updates.jsonl']);
|
|
54
|
+
const sameContext = contextSource != null && previous?.contextFingerprint === contextSource.fingerprint;
|
|
55
|
+
const sameUpdates = updateSource != null && previous?.updateFingerprint === updateSource.fingerprint;
|
|
56
|
+
if (sameContext && sameUpdates) return previous!.result;
|
|
57
|
+
const loadedVisible = sameUpdates ? undefined : load.visible();
|
|
58
|
+
const visible = sameUpdates ? previous!.visible
|
|
59
|
+
: loadedVisible === undefined ? undefined : structuredClone(loadedVisible);
|
|
60
|
+
const context = sameContext && !(visible === undefined && previous!.omittedContextEntries)
|
|
61
|
+
? previous!.context : load.context();
|
|
62
|
+
const result = withGrokVisible(context, visible);
|
|
63
|
+
const unchangedContext = contextSource != null && contextSource.fingerprint
|
|
64
|
+
=== source(identity, ['chat_history.jsonl', 'events.jsonl', 'rewind_points.jsonl'])?.fingerprint;
|
|
65
|
+
const unchangedUpdates = updateSource != null && updateSource.fingerprint === source(identity, ['updates.jsonl'])?.fingerprint;
|
|
66
|
+
if (unchangedContext && unchangedUpdates && context.cacheable !== false) {
|
|
67
|
+
this.entries.set(key, { contextFingerprint: contextSource!.fingerprint,
|
|
68
|
+
updateFingerprint: visible !== undefined || !updateSource!.present ? updateSource!.fingerprint : undefined,
|
|
69
|
+
context: visible !== undefined ? { ...context, entries: [] } : context,
|
|
70
|
+
omittedContextEntries: visible !== undefined, visible, result });
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -21,6 +21,7 @@ import { grokNativeActivity } from '../../providers/grok-activity';
|
|
|
21
21
|
import { grokTimeline } from '../../providers/grok-timeline';
|
|
22
22
|
import { grokToolCalls, grokToolCompletions, grokToolSummary, grokRecordedChanges } from '../../providers/grok-tools';
|
|
23
23
|
import { readGrokConversation } from '../../providers/grok-conversation';
|
|
24
|
+
import { GrokCatalogCache, type GrokParsedContext } from '../../providers/grok-catalog-cache';
|
|
24
25
|
|
|
25
26
|
type Summary = {
|
|
26
27
|
info?: { id?: unknown; cwd?: unknown };
|
|
@@ -31,15 +32,7 @@ type Summary = {
|
|
|
31
32
|
current_model_id?: unknown;
|
|
32
33
|
};
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
entries: ActivityEntry[];
|
|
36
|
-
usage: CapabilityObservation[];
|
|
37
|
-
firstUser?: string;
|
|
38
|
-
lastMessageAt?: number;
|
|
39
|
-
tokens: number;
|
|
40
|
-
lastTurn: number;
|
|
41
|
-
};
|
|
42
|
-
|
|
35
|
+
const catalogCache = new GrokCatalogCache();
|
|
43
36
|
let activityBySession = new Map<string, ActivityEntry[]>();
|
|
44
37
|
let logPaths = new Map<string, string>();
|
|
45
38
|
let usageBySession = new Map<string, CapabilityObservation[]>();
|
|
@@ -84,9 +77,14 @@ function visibleRole(row: Record<string, unknown>, message: Record<string, unkno
|
|
|
84
77
|
return typeof type === "string" ? type : "";
|
|
85
78
|
}
|
|
86
79
|
|
|
87
|
-
function parseLog(path: string, sessionId: string, fallbackTime: number, fullText = false):
|
|
80
|
+
function parseLog(path: string, sessionId: string, fallbackTime: number, fullText = false): GrokParsedContext {
|
|
88
81
|
let lines: string[];
|
|
89
|
-
|
|
82
|
+
let cacheable = true;
|
|
83
|
+
try { lines = readFileSync(path, "utf8").split("\n"); }
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (fullText && (error as NodeJS.ErrnoException).code !== 'ENOENT') throw Error('The complete Grok conversation could not be read.');
|
|
86
|
+
lines = []; cacheable = (error as NodeJS.ErrnoException).code === 'ENOENT';
|
|
87
|
+
}
|
|
90
88
|
const entries: ActivityEntry[] = [];
|
|
91
89
|
const usage: CapabilityObservation[] = [];
|
|
92
90
|
const seen = new Set<string>();
|
|
@@ -99,7 +97,10 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
|
|
|
99
97
|
let prompt: { at: number; row: number } | undefined;
|
|
100
98
|
lines.forEach((line, index) => {
|
|
101
99
|
const row = safeParse(line);
|
|
102
|
-
if (!row)
|
|
100
|
+
if (!row) {
|
|
101
|
+
if (fullText && line.trim()) throw Error('The full native Grok conversation is incomplete or still being written.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
103
104
|
const message = row.message && typeof row.message === "object" ? row.message : row;
|
|
104
105
|
const role = visibleRole(row, message);
|
|
105
106
|
if (role === "reasoning" || role === "system") return;
|
|
@@ -118,7 +119,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
|
|
|
118
119
|
const text = texts.join("\n").trim();
|
|
119
120
|
if (text) {
|
|
120
121
|
if (messageAt || prompt) lastMessageAt = Math.max(lastMessageAt, createdAt);
|
|
121
|
-
firstUser ??= text;
|
|
122
|
+
firstUser ??= text.slice(0, 120);
|
|
122
123
|
pushEntry({ fullText, out: entries, seen, sessionId, kind: "user", text, createdAt, ordinal: index,
|
|
123
124
|
idOverride: `${sessionId}:${legacyTime}:${index}` });
|
|
124
125
|
tokens += estimateTokens(text);
|
|
@@ -162,13 +163,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
|
|
|
162
163
|
last.kind = 'final'; last.createdAt = timeline.completedAt;
|
|
163
164
|
lastMessageAt = Math.max(lastMessageAt, timeline.completedAt);
|
|
164
165
|
}
|
|
165
|
-
|
|
166
|
-
for (const entry of visible) {
|
|
167
|
-
if (entry.kind === 'user') firstUser ??= entry.text;
|
|
168
|
-
if (['user', 'message', 'final'].includes(entry.kind)) lastMessageAt = Math.max(lastMessageAt, entry.createdAt);
|
|
169
|
-
}
|
|
170
|
-
return { entries: visible,
|
|
171
|
-
usage, firstUser, lastMessageAt: lastMessageAt || undefined, tokens, lastTurn };
|
|
166
|
+
return { entries, usage, firstUser, lastMessageAt: lastMessageAt || undefined, tokens, lastTurn, cacheable };
|
|
172
167
|
}
|
|
173
168
|
|
|
174
169
|
function parseSummary(path: string): Summary | null {
|
|
@@ -181,6 +176,10 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
|
|
|
181
176
|
const usages = new Map<string, CapabilityObservation[]>();
|
|
182
177
|
let tokensRecent = 0;
|
|
183
178
|
const listed = recentSummaries(grokSessionsRoot(), maxFiles + 1);
|
|
179
|
+
logPaths = new Map();
|
|
180
|
+
const selected = new Set(listed.slice(0, maxFiles).map(path => dirname(path)));
|
|
181
|
+
catalogCache.retain(selected);
|
|
182
|
+
const admitted = new Set<string>();
|
|
184
183
|
for (const path of listed.slice(0, maxFiles)) {
|
|
185
184
|
const summary = parseSummary(path);
|
|
186
185
|
if (!summary) continue;
|
|
@@ -188,13 +187,17 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
|
|
|
188
187
|
const sessionId = typeof summary.info?.id === "string"
|
|
189
188
|
? summary.info.id : basename(directory);
|
|
190
189
|
if (!sessionId || sessionId.length > 256) continue;
|
|
190
|
+
admitted.add(directory);
|
|
191
191
|
let stat;
|
|
192
192
|
try { stat = statSync(join(directory, "chat_history.jsonl")); } catch {
|
|
193
193
|
try { stat = statSync(path); } catch { continue; }
|
|
194
194
|
}
|
|
195
195
|
const updatedAt = ts(summary.updated_at) || stat.mtimeMs;
|
|
196
196
|
const startedAt = ts(summary.created_at) || stat.birthtimeMs || updatedAt;
|
|
197
|
-
const parsed =
|
|
197
|
+
const parsed = catalogCache.read({ directory, sessionId, fallbackTime: startedAt }, {
|
|
198
|
+
context: () => parseLog(join(directory, 'chat_history.jsonl'), sessionId, startedAt),
|
|
199
|
+
visible: () => readGrokConversation(directory, sessionId),
|
|
200
|
+
});
|
|
198
201
|
const title = [summary.generated_title, summary.session_summary, parsed.firstUser]
|
|
199
202
|
.find((value): value is string => typeof value === "string" && value.trim().length > 0);
|
|
200
203
|
const cwd = typeof summary.info?.cwd === "string" ? summary.info.cwd : undefined;
|
|
@@ -213,6 +216,7 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
|
|
|
213
216
|
if (Date.now() - updatedAt <= TOKEN_WINDOW_MS) tokensRecent += parsed.tokens;
|
|
214
217
|
}
|
|
215
218
|
activityBySession = activities;
|
|
219
|
+
catalogCache.retain(admitted);
|
|
216
220
|
usageBySession = usages;
|
|
217
221
|
return { sessions, tokensRecent, sourceLimited: listed.length > maxFiles };
|
|
218
222
|
}
|
|
@@ -220,7 +224,11 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
|
|
|
220
224
|
export function grokActivity(session: SessionInfo, fullText = false): ActivityEntry[] {
|
|
221
225
|
if (!activityBySession.has(session.sessionId)) scanGrok();
|
|
222
226
|
const path = logPaths.get(session.sessionId);
|
|
223
|
-
if (fullText && path)
|
|
227
|
+
if (fullText && path) {
|
|
228
|
+
const native = readGrokConversation(dirname(path), session.sessionId, true);
|
|
229
|
+
if (native !== undefined) return structuredClone(native);
|
|
230
|
+
return parseLog(path, session.sessionId, session.startedAt, true).entries;
|
|
231
|
+
}
|
|
224
232
|
return activityBySession.get(session.sessionId) ?? [];
|
|
225
233
|
}
|
|
226
234
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Task schedules
|
|
2
|
+
|
|
3
|
+
Public entry: `handleTaskSchedules`. The paired controller lists and edits existing
|
|
4
|
+
Codex heartbeat automations attached to admitted executions of the same Task and
|
|
5
|
+
Project. GrantTap does not create a scheduler or start provider turns here.
|
|
6
|
+
|
|
7
|
+
The local Codex version-1 mirror and native scheduling row must agree. Updates
|
|
8
|
+
preserve unknown fields and execution settings, use an expected revision, retain
|
|
9
|
+
private before/proposed checkpoints, and roll back the mirror if the native
|
|
10
|
+
transaction fails. Cloud migration, ambiguous databases, unsupported storage and
|
|
11
|
+
native edits make controls read-only. Pause removes future scheduling without
|
|
12
|
+
cancelling a running turn. Intervals use elapsed time; wall-clock rules use the
|
|
13
|
+
computer's time zone. There is no background polling.
|
|
14
|
+
|
|
15
|
+
Requests expire, repeat identities cannot repeat mutations, and replies go only
|
|
16
|
+
to the requesting paired controller under the chat's key. Content and identifiers
|
|
17
|
+
are bounded. Checkpoints remain private computer data, not disposable cache.
|
|
18
|
+
|
|
19
|
+
Tests beside this entry exercise native persistence, rollback, external changes,
|
|
20
|
+
scope, authenticated routing, lost receipts and repeated taps using isolated stores.
|
|
21
|
+
|
|
22
|
+
License: first-party code is under the GrantTap Commercial Source License in the repository LICENSE.
|
|
23
|
+
Third-party dependencies retain their own licenses and notices.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Native schedule cadence
|
|
2
|
+
|
|
3
|
+
Public entry: `validateScheduleRule` and `nextNativeOccurrence`. Accept the exact
|
|
4
|
+
minute/hour interval and daily/weekly wall-clock forms editable by the phone.
|
|
5
|
+
Compute only the next native timestamp when explicitly enabling or changing a
|
|
6
|
+
schedule. This module owns no timer and never executes a Task. The computer's
|
|
7
|
+
civil time handles daylight-saving changes; elapsed intervals remain durations.
|
|
8
|
+
|
|
9
|
+
First-party code is under the GrantTap Commercial Source License.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
type Schedule = { frequency: string; interval: number; hour: number; minute: number; days: Set<string> };
|
|
2
|
+
|
|
3
|
+
function parse(rule: string): Schedule | undefined {
|
|
4
|
+
const pairs = rule.replace(/^RRULE:/, '').split(';').map(part => part.split('='));
|
|
5
|
+
if (pairs.some(pair => pair.length !== 2)) return undefined;
|
|
6
|
+
const fields = new Map(pairs.map(([key, value]) => [key!, value!]));
|
|
7
|
+
if (fields.size !== pairs.length) return undefined;
|
|
8
|
+
const frequency = fields.get('FREQ') ?? '', intervalText = fields.get('INTERVAL') ?? '1';
|
|
9
|
+
if (!/^[1-9]\d{0,3}$/.test(intervalText)) return undefined;
|
|
10
|
+
const interval = Number(intervalText);
|
|
11
|
+
if (['MINUTELY', 'HOURLY'].includes(frequency)) {
|
|
12
|
+
return [...fields.keys()].every(key => ['FREQ', 'INTERVAL'].includes(key))
|
|
13
|
+
? { frequency, interval, hour: 0, minute: 0, days: new Set() } : undefined;
|
|
14
|
+
}
|
|
15
|
+
if (!['DAILY', 'WEEKLY'].includes(frequency) || interval !== 1
|
|
16
|
+
|| [...fields.keys()].some(key => !['FREQ', 'INTERVAL', 'BYHOUR', 'BYMINUTE', 'BYDAY'].includes(key))) return undefined;
|
|
17
|
+
const hourText = fields.get('BYHOUR') ?? '', minuteText = fields.get('BYMINUTE') ?? '';
|
|
18
|
+
if (!/^\d{1,2}$/.test(hourText) || Number(hourText) > 23 || !/^\d{1,2}$/.test(minuteText) || Number(minuteText) > 59) return undefined;
|
|
19
|
+
const dayValues = fields.has('BYDAY') ? fields.get('BYDAY')!.split(',') : [], days = new Set(dayValues);
|
|
20
|
+
if (frequency === 'DAILY' && fields.has('BYDAY')) return undefined;
|
|
21
|
+
if (frequency === 'WEEKLY' && (days.size === 0 || days.size !== dayValues.length
|
|
22
|
+
|| [...days].some(day => !['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'].includes(day)))) return undefined;
|
|
23
|
+
return { frequency, interval, hour: Number(hourText), minute: Number(minuteText), days };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateScheduleRule(rule: string): boolean { return parse(rule) !== undefined; }
|
|
27
|
+
|
|
28
|
+
/** One next native timestamp, not a scheduler. Civil dates keep daily/weekly times through DST. */
|
|
29
|
+
export function nextNativeOccurrence(rule: string, now: number): number {
|
|
30
|
+
const schedule = parse(rule);
|
|
31
|
+
if (!schedule) throw Error('Choose a supported schedule.');
|
|
32
|
+
const current = new Date(now);
|
|
33
|
+
if (!Number.isFinite(current.getTime())) throw Error('The computer clock is unavailable.');
|
|
34
|
+
if (['MINUTELY', 'HOURLY'].includes(schedule.frequency)) {
|
|
35
|
+
return now + (schedule.frequency === 'MINUTELY' ? 60_000 : 3_600_000) * schedule.interval;
|
|
36
|
+
}
|
|
37
|
+
for (let offset = 0; offset <= 7; offset++) {
|
|
38
|
+
const candidate = new Date(current.getFullYear(), current.getMonth(), current.getDate() + offset, schedule.hour, schedule.minute);
|
|
39
|
+
const day = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][candidate.getDay()]!;
|
|
40
|
+
if (candidate.getTime() > now && (schedule.days.size === 0 || schedule.days.has(day))) return candidate.getTime();
|
|
41
|
+
}
|
|
42
|
+
throw Error('This schedule has no future occurrence.');
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { stringify } from 'smol-toml';
|
|
5
|
+
import { TaskSchedulePatch } from '../../../../packages/protocol/task-schedules';
|
|
6
|
+
import type { ScheduleRepositoryOptions, StoredSchedule } from '.';
|
|
7
|
+
import { changeNativeSchedule } from './native-change';
|
|
8
|
+
import { validateScheduleRule } from './cadence';
|
|
9
|
+
|
|
10
|
+
function privateCheckpoint(path: string, bytes: string): void {
|
|
11
|
+
let descriptor: number | undefined;
|
|
12
|
+
try {
|
|
13
|
+
descriptor = openSync(path, 'wx', 0o600);
|
|
14
|
+
writeFileSync(descriptor, bytes); fsyncSync(descriptor);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || readFileSync(path, 'utf8') !== bytes) throw error;
|
|
18
|
+
}
|
|
19
|
+
finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function retainRecentCheckpoints(root: string, id: string, retained: string[]): void {
|
|
23
|
+
try {
|
|
24
|
+
const pattern = new RegExp('^' + id + '-[a-f0-9]{64}-(before|proposed-[a-f0-9]{64})\\.toml$');
|
|
25
|
+
const paths = readdirSync(root).filter(name => pattern.test(name)).map(name => join(root, name))
|
|
26
|
+
.filter(path => lstatSync(path).isFile()).sort((a, b) => lstatSync(b).mtimeMs - lstatSync(a).mtimeMs);
|
|
27
|
+
for (const path of paths.slice(20)) if (!retained.includes(path)) rmSync(path);
|
|
28
|
+
} catch { /* Retention cannot turn a successful native save into a failed edit. */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Both the old and proposed configuration survive a failed or concurrent write. */
|
|
32
|
+
export function changeStoredSchedule(stored: StoredSchedule, revision: string, input: TaskSchedulePatch,
|
|
33
|
+
options: ScheduleRepositoryOptions): void {
|
|
34
|
+
if (stored.schedule.revision !== revision) throw Error('This automation changed. Refresh before editing it.');
|
|
35
|
+
if (!stored.schedule.editable) throw Error(stored.schedule.reason ?? 'This automation is read-only.');
|
|
36
|
+
const patch = TaskSchedulePatch.parse(input);
|
|
37
|
+
const needsRule = patch.rrule != null || patch.status === 'ACTIVE' && stored.schedule.status !== 'ACTIVE';
|
|
38
|
+
if (needsRule && !validateScheduleRule(patch.rrule ?? stored.schedule.rrule)) throw Error('Choose a supported schedule.');
|
|
39
|
+
const now = options.now?.() ?? Date.now();
|
|
40
|
+
if (!Number.isSafeInteger(now) || now < 0) throw Error('The computer clock is unavailable.');
|
|
41
|
+
const record = { ...stored.record, ...patch, updated_at: Math.max(now, Number(stored.record.updated_at ?? 0) + 1) };
|
|
42
|
+
const proposed = stringify(record);
|
|
43
|
+
if (Buffer.byteLength(proposed) > 65_536) throw Error('The automation exceeds its native file size limit.');
|
|
44
|
+
mkdirSync(options.backupRoot, { recursive: true, mode: 0o700 });
|
|
45
|
+
const base = stored.schedule.id + '-' + revision;
|
|
46
|
+
const before = join(options.backupRoot, base + '-before.toml');
|
|
47
|
+
const after = join(options.backupRoot, base + '-proposed-' + createHash('sha256').update(proposed).digest('hex') + '.toml');
|
|
48
|
+
privateCheckpoint(before, stored.text);
|
|
49
|
+
privateCheckpoint(after, proposed);
|
|
50
|
+
options.beforeCommit?.();
|
|
51
|
+
changeNativeSchedule(stored, record, { root: options.root, now, writeMirror: () => {
|
|
52
|
+
const latest = lstatSync(stored.path);
|
|
53
|
+
if (latest.isSymbolicLink() || !latest.isFile() || latest.size > 65_536
|
|
54
|
+
|| createHash('sha256').update(readFileSync(stored.path)).digest('hex') !== revision) {
|
|
55
|
+
throw Error('This automation changed. Refresh before editing it.');
|
|
56
|
+
}
|
|
57
|
+
replaceFile(stored.path, proposed);
|
|
58
|
+
}, restoreMirror: () => {
|
|
59
|
+
if (readFileSync(stored.path, 'utf8') === proposed) replaceFile(stored.path, stored.text);
|
|
60
|
+
} });
|
|
61
|
+
retainRecentCheckpoints(options.backupRoot, stored.schedule.id, [before, after]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function replaceFile(path: string, text: string): void {
|
|
65
|
+
const temporary = join(dirname(path), '.granttap-schedule-' + randomUUID());
|
|
66
|
+
let descriptor: number | undefined;
|
|
67
|
+
try {
|
|
68
|
+
descriptor = openSync(temporary, 'wx', 0o600);
|
|
69
|
+
writeFileSync(descriptor, text); fsyncSync(descriptor);
|
|
70
|
+
closeSync(descriptor); descriptor = undefined;
|
|
71
|
+
renameSync(temporary, path);
|
|
72
|
+
} finally {
|
|
73
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
74
|
+
rmSync(temporary, { force: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { TaskSchedulesRequest, TaskSchedulesResult } from '../../../../packages/protocol/task-schedules';
|
|
3
|
+
import type { createTaskScheduleRepository } from '.';
|
|
4
|
+
|
|
5
|
+
type Repository = ReturnType<typeof createTaskScheduleRepository>;
|
|
6
|
+
type Delivery = { fingerprint: string; result: TaskSchedulesResult; at: number };
|
|
7
|
+
type Context = { controller: string; sessions: readonly string[]; send: (result: TaskSchedulesResult) => Promise<void> };
|
|
8
|
+
|
|
9
|
+
/** Edits are addressed to an existing native automation and idempotent per paired controller. */
|
|
10
|
+
export class TaskScheduleControls {
|
|
11
|
+
private readonly deliveries = new Map<string, Delivery>();
|
|
12
|
+
constructor(private readonly repository: Repository, private readonly now: () => number = Date.now) {}
|
|
13
|
+
|
|
14
|
+
async handle(input: TaskSchedulesRequest, context: Context): Promise<boolean> {
|
|
15
|
+
const parsed = TaskSchedulesRequest.safeParse(input);
|
|
16
|
+
if (!parsed.success || Math.abs(this.now() - parsed.data.createdAt) > 120_000) return false;
|
|
17
|
+
const request = parsed.data, at = this.now();
|
|
18
|
+
for (const [key, value] of this.deliveries) if (at - value.at > 120_000) this.deliveries.delete(key);
|
|
19
|
+
const key = JSON.stringify([context.controller, request.requestId]);
|
|
20
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
|
21
|
+
const previous = this.deliveries.get(key);
|
|
22
|
+
if (previous) {
|
|
23
|
+
if (previous.fingerprint !== fingerprint) return false;
|
|
24
|
+
await context.send(previous.result); return true;
|
|
25
|
+
}
|
|
26
|
+
if (this.deliveries.size >= 128) {
|
|
27
|
+
await context.send({ ...this.base(request, at), phase: 'unavailable',
|
|
28
|
+
message: 'Too many automation requests. Wait briefly and refresh status.' });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const result = this.prepare(request, context.sessions, at);
|
|
32
|
+
this.deliveries.set(key, { fingerprint, result, at });
|
|
33
|
+
await context.send(result);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private prepare(request: TaskSchedulesRequest, sessions: readonly string[], at: number): TaskSchedulesResult {
|
|
38
|
+
const base = this.base(request, at);
|
|
39
|
+
if (sessions.length === 0) return { ...base, phase: 'unavailable',
|
|
40
|
+
message: 'Codex automations are unavailable for this chat on this computer.' };
|
|
41
|
+
try {
|
|
42
|
+
const change = request.change;
|
|
43
|
+
const updated = change ? this.repository.change(change.id, change.revision, change.patch, sessions) : undefined;
|
|
44
|
+
const list = this.repository.list(sessions);
|
|
45
|
+
const schedules = updated ? [updated, ...list.schedules.filter(row => row.id !== updated.id)] : [...list.schedules];
|
|
46
|
+
let truncated = list.truncated;
|
|
47
|
+
while (schedules.length > 32 || Buffer.byteLength(JSON.stringify(schedules)) > 60_000) { schedules.pop(); truncated = true; }
|
|
48
|
+
return TaskSchedulesResult.parse({ ...base, schedules, truncated,
|
|
49
|
+
phase: updated && updated.confirmation !== 'confirmed' ? 'waiting' : 'completed' });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Parser and native-storage errors can contain private configuration. Return only owned messages.
|
|
52
|
+
const message = error instanceof Error && /^(This automation |Choose a supported schedule\.|The native scheduler is unavailable\.|The computer clock is unavailable\.)/.test(error.message)
|
|
53
|
+
? error.message.slice(0, 240) : 'The computer could not apply this change. Refresh before retrying.';
|
|
54
|
+
return { ...base, phase: 'failed', message };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private base(request: TaskSchedulesRequest, at: number) {
|
|
59
|
+
return { type: 'task.schedules.result' as const, requestId: request.requestId,
|
|
60
|
+
sessionId: request.sessionId, schedules: [], truncated: false,
|
|
61
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, createdAt: at };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isUtf8 } from 'node:buffer';
|
|
3
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parse, type TomlTable } from 'smol-toml';
|
|
6
|
+
import { TaskSchedule, TaskSchedulePatch } from '../../../../packages/protocol/task-schedules';
|
|
7
|
+
import { changeStoredSchedule } from './changes';
|
|
8
|
+
import { nativeScheduleState } from './native-state';
|
|
9
|
+
|
|
10
|
+
export type ScheduleRepositoryOptions = { root: string; backupRoot: string; now?: () => number; beforeCommit?: () => void };
|
|
11
|
+
export type StoredSchedule = { path: string; text: string; record: TomlTable; schedule: TaskSchedule };
|
|
12
|
+
const validId = (id: string) => /^[a-zA-Z0-9_-]{1,160}$/.test(id);
|
|
13
|
+
|
|
14
|
+
/** Native Codex heartbeat records; no parallel scheduler or provider execution. */
|
|
15
|
+
export function createTaskScheduleRepository(options: ScheduleRepositoryOptions) {
|
|
16
|
+
const directory = join(options.root, 'automations');
|
|
17
|
+
function read(id: string, sessions: readonly string[]): StoredSchedule | undefined {
|
|
18
|
+
if (!validId(id)) return undefined;
|
|
19
|
+
const folder = join(directory, id), path = join(folder, 'automation.toml');
|
|
20
|
+
try {
|
|
21
|
+
if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()
|
|
22
|
+
|| !lstatSync(folder).isDirectory() || lstatSync(folder).isSymbolicLink()) return undefined;
|
|
23
|
+
const stat = lstatSync(path);
|
|
24
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65_536) return undefined;
|
|
25
|
+
const bytes = readFileSync(path);
|
|
26
|
+
if (!isUtf8(bytes)) return undefined;
|
|
27
|
+
const text = bytes.toString('utf8'), record = parse(text);
|
|
28
|
+
if (record.version !== 1 || record.id !== id || record.kind !== 'heartbeat'
|
|
29
|
+
|| typeof record.target_thread_id !== 'string' || !sessions.includes(record.target_thread_id)
|
|
30
|
+
|| !Number.isSafeInteger(record.created_at) || Number(record.created_at) < 0
|
|
31
|
+
|| !Number.isSafeInteger(record.updated_at) || Number(record.updated_at) < 0) return undefined;
|
|
32
|
+
const parsed = TaskSchedule.safeParse({ id, sessionId: record.target_thread_id,
|
|
33
|
+
name: record.name, prompt: record.prompt, rrule: record.rrule, status: record.status === 'DISABLED' ? 'PAUSED' : record.status,
|
|
34
|
+
revision: createHash('sha256').update(text).digest('hex'), editable: true, confirmation: 'unavailable' });
|
|
35
|
+
if (!parsed.success) return undefined;
|
|
36
|
+
const schedule = { ...parsed.data, ...nativeScheduleState(options.root, parsed.data, record) };
|
|
37
|
+
return { path, text, record, schedule };
|
|
38
|
+
} catch { return undefined; }
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
list(sessions: readonly string[]) {
|
|
42
|
+
if (!existsSync(directory)) return { schedules: [] as TaskSchedule[], truncated: false };
|
|
43
|
+
const names = readdirSync(directory).slice(0, 512);
|
|
44
|
+
const schedules: TaskSchedule[] = [];
|
|
45
|
+
let bytes = 0, truncated = names.length === 512;
|
|
46
|
+
for (const id of names) {
|
|
47
|
+
const item = read(id, sessions)?.schedule;
|
|
48
|
+
if (!item) continue;
|
|
49
|
+
const size = Buffer.byteLength(JSON.stringify(item));
|
|
50
|
+
if (schedules.length === 32 || bytes + size > 60_000) { truncated = true; break; }
|
|
51
|
+
schedules.push(item); bytes += size;
|
|
52
|
+
}
|
|
53
|
+
return { schedules, truncated };
|
|
54
|
+
},
|
|
55
|
+
change(id: string, revision: string, patch: TaskSchedulePatch, sessions: readonly string[]): TaskSchedule {
|
|
56
|
+
const stored = read(id, sessions);
|
|
57
|
+
if (!stored) throw Error('This automation is unavailable in this chat.');
|
|
58
|
+
changeStoredSchedule(stored, revision, patch, options);
|
|
59
|
+
const saved = read(id, sessions);
|
|
60
|
+
if (!saved) throw Error('The native automation changed after saving. Refresh its status.');
|
|
61
|
+
return saved.schedule;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DatabaseSync, type SQLOutputValue } from 'node:sqlite';
|
|
2
|
+
import type { TomlTable } from 'smol-toml';
|
|
3
|
+
import type { StoredSchedule } from '.';
|
|
4
|
+
import { findNativeSchedule, sameNativeSchedule } from './native-state';
|
|
5
|
+
import { nextNativeOccurrence } from './cadence';
|
|
6
|
+
|
|
7
|
+
function nativeTimestamp(value: SQLOutputValue | undefined): number | null {
|
|
8
|
+
if (value === null) return null;
|
|
9
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
|
|
10
|
+
throw Error('The native scheduler is unavailable.');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Commit the native scheduling state and its mirror together; never start a provider run. */
|
|
14
|
+
export function changeNativeSchedule(stored: StoredSchedule, record: TomlTable,
|
|
15
|
+
commit: { root: string; now: number; writeMirror: () => void; restoreMirror: () => void }): void {
|
|
16
|
+
const store = findNativeSchedule(commit.root, stored.schedule.id);
|
|
17
|
+
if (!store) throw Error('The native scheduler is unavailable.');
|
|
18
|
+
const database = new DatabaseSync(store.path);
|
|
19
|
+
let fileChanged = false;
|
|
20
|
+
try {
|
|
21
|
+
database.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=250; BEGIN IMMEDIATE;');
|
|
22
|
+
const row = database.prepare('SELECT * FROM automations WHERE id=?').get(stored.schedule.id);
|
|
23
|
+
if (!row || !sameNativeSchedule(row, stored.schedule, stored.record)) throw Error('This automation changed. Refresh before editing.');
|
|
24
|
+
const reschedule = record.status === 'ACTIVE'
|
|
25
|
+
&& (row.status !== 'ACTIVE' || row.rrule !== record.rrule);
|
|
26
|
+
const next = record.status === 'PAUSED' ? null : reschedule
|
|
27
|
+
? nextNativeOccurrence(String(record.rrule), commit.now) : nativeTimestamp(row.next_run_at);
|
|
28
|
+
const nominal = record.status === 'PAUSED' ? null : reschedule ? next : nativeTimestamp(row.next_run_nominal_at);
|
|
29
|
+
const result = database.prepare(`UPDATE automations SET name=?,prompt=?,status=?,rrule=?,updated_at=?,
|
|
30
|
+
next_run_at=?,next_run_nominal_at=? WHERE id=? AND updated_at=? AND target_thread_id=?
|
|
31
|
+
AND kind='heartbeat' AND account_id IS NULL AND user_id IS NULL AND installation_id IS NULL`)
|
|
32
|
+
.run(String(record.name), String(record.prompt), String(record.status), String(record.rrule),
|
|
33
|
+
Number(record.updated_at), next, nominal, stored.schedule.id,
|
|
34
|
+
Number(stored.record.updated_at), stored.schedule.sessionId);
|
|
35
|
+
if (result.changes !== 1) throw Error('This automation changed. Refresh before editing.');
|
|
36
|
+
commit.writeMirror(); fileChanged = true;
|
|
37
|
+
database.exec('COMMIT;');
|
|
38
|
+
} catch (error) {
|
|
39
|
+
try { database.exec('ROLLBACK;'); } catch { /* No open transaction after a failed BEGIN. */ }
|
|
40
|
+
if (fileChanged) commit.restoreMirror();
|
|
41
|
+
throw error;
|
|
42
|
+
} finally { database.close(); }
|
|
43
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DatabaseSync, type SQLOutputValue } from 'node:sqlite';
|
|
4
|
+
import type { TaskSchedule } from '../../../../packages/protocol/task-schedules';
|
|
5
|
+
import type { TomlTable } from 'smol-toml';
|
|
6
|
+
|
|
7
|
+
export type NativeScheduleRow = Record<string, SQLOutputValue>;
|
|
8
|
+
export type NativeScheduleStore = { path: string; row: NativeScheduleRow };
|
|
9
|
+
const columns = `id,name,prompt,status,rrule,updated_at,target_thread_id,kind,
|
|
10
|
+
account_id,user_id,installation_id,next_run_at,next_run_nominal_at`;
|
|
11
|
+
|
|
12
|
+
function localScheduler(root: string): boolean {
|
|
13
|
+
const path = join(root, '.codex-global-state.json');
|
|
14
|
+
if (!existsSync(path)) return true;
|
|
15
|
+
try {
|
|
16
|
+
const stat = lstatSync(path);
|
|
17
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) return false;
|
|
18
|
+
const state = JSON.parse(readFileSync(path, 'utf8'));
|
|
19
|
+
const migration = state?.['electron-persisted-atom-state']?.['electron-automations-habitat-migration-state'];
|
|
20
|
+
return migration === undefined || migration != null && typeof migration === 'object'
|
|
21
|
+
&& !Array.isArray(migration) && Object.keys(migration).length === 0;
|
|
22
|
+
} catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only a single, local native record can be controlled. Cloud ownership is never inferred. */
|
|
26
|
+
export function findNativeSchedule(root: string, id: string): NativeScheduleStore | undefined {
|
|
27
|
+
if (!localScheduler(root)) return undefined;
|
|
28
|
+
const matches: NativeScheduleStore[] = [];
|
|
29
|
+
for (const name of ['codex-dev.db', 'codex.db']) {
|
|
30
|
+
const path = join(root, 'sqlite', name);
|
|
31
|
+
let database: DatabaseSync | undefined;
|
|
32
|
+
try {
|
|
33
|
+
if (![root, join(root, 'sqlite')].every(item => lstatSync(item).isDirectory()
|
|
34
|
+
&& !lstatSync(item).isSymbolicLink())) continue;
|
|
35
|
+
if (!existsSync(path) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) continue;
|
|
36
|
+
database = new DatabaseSync(path, { readOnly: true });
|
|
37
|
+
database.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=50;');
|
|
38
|
+
const rows = database.prepare(`SELECT ${columns} FROM automations WHERE id=? OR legacy_automation_id=? LIMIT 2`).all(id, id);
|
|
39
|
+
for (const row of rows) matches.push({ path, row });
|
|
40
|
+
} catch { /* Locked, unsupported or incomplete stores remain read-only. */ }
|
|
41
|
+
finally { database?.close(); }
|
|
42
|
+
}
|
|
43
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sameNativeSchedule(row: NativeScheduleRow, schedule: TaskSchedule, record: TomlTable): boolean {
|
|
47
|
+
return row.id === schedule.id && row.kind === 'heartbeat' && row.account_id == null
|
|
48
|
+
&& row.user_id == null && row.installation_id == null && row.name === schedule.name
|
|
49
|
+
&& row.prompt === schedule.prompt && row.status === schedule.status && row.rrule === schedule.rrule
|
|
50
|
+
&& row.target_thread_id === schedule.sessionId && row.updated_at === record.updated_at;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nativeScheduleState(root: string, schedule: TaskSchedule, record: TomlTable): Pick<TaskSchedule, 'editable' | 'confirmation' | 'reason'> {
|
|
54
|
+
const store = findNativeSchedule(root, schedule.id);
|
|
55
|
+
if (!store) return { editable: false, confirmation: 'unavailable', reason: 'The native scheduler is unavailable or has an unsupported storage version.' };
|
|
56
|
+
if (store.row.account_id != null || store.row.user_id != null || store.row.installation_id != null) {
|
|
57
|
+
return { editable: false, confirmation: 'unavailable', reason: 'This automation is managed by the Codex account.' };
|
|
58
|
+
}
|
|
59
|
+
if (!sameNativeSchedule(store.row, schedule, record)) {
|
|
60
|
+
return { editable: false, confirmation: 'waiting', reason: 'The native automation changed. Refresh it in Codex before editing.' };
|
|
61
|
+
}
|
|
62
|
+
return { editable: true, confirmation: 'confirmed' };
|
|
63
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import type { RelayClient } from '../../../../packages/core/relay-client';
|
|
4
|
+
import { TaskSchedulesRequest } from '../../../../packages/protocol/task-schedules';
|
|
5
|
+
import { configDir, loadConfig, machineConfigPath } from '../config';
|
|
6
|
+
import { publishedCatalog, publishedSession } from '../session-catalog';
|
|
7
|
+
import { sendSessionPayload } from '../host/session-keys';
|
|
8
|
+
import { createTaskScheduleRepository } from '.';
|
|
9
|
+
import { TaskScheduleControls } from './controls';
|
|
10
|
+
|
|
11
|
+
let current: { root: string; controls: TaskScheduleControls } | undefined;
|
|
12
|
+
|
|
13
|
+
/** Task scope comes from admitted executions. Native paths and peer identity are never phone supplied. */
|
|
14
|
+
export async function handleTaskSchedules(client: RelayClient, input: TaskSchedulesRequest,
|
|
15
|
+
peer: string | undefined): Promise<boolean> {
|
|
16
|
+
const parsed = TaskSchedulesRequest.safeParse(input);
|
|
17
|
+
if (!peer || !parsed.success || Math.abs(Date.now() - parsed.data.createdAt) > 120_000) return false;
|
|
18
|
+
const request = parsed.data;
|
|
19
|
+
try {
|
|
20
|
+
const machine = loadConfig(machineConfigPath());
|
|
21
|
+
if (machine.role !== 'machine' || machine.room !== client.room
|
|
22
|
+
|| machine.peerPublicKey !== peer && !machine.extraPeerPublicKeys?.includes(peer)) return false;
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
const session = publishedSession(client, request.sessionId);
|
|
25
|
+
if (!session) return false;
|
|
26
|
+
const catalog = publishedCatalog(client);
|
|
27
|
+
const candidates = [...(catalog?.sessions ?? []), ...(catalog?.history ?? [])];
|
|
28
|
+
const sessions = [...new Set(candidates.filter(item => item.agent === 'codex'
|
|
29
|
+
&& (item.sessionId === session.sessionId || session.projectId != null && session.taskId != null
|
|
30
|
+
&& item.projectId === session.projectId && item.taskId === session.taskId)).map(item => item.sessionId))];
|
|
31
|
+
const root = process.env.GRANTTAP_CODEX_DIR ?? process.env.NODVOX_CODEX_DIR ?? process.env.CODEX_HOME ?? join(homedir(), '.codex');
|
|
32
|
+
if (!isAbsolute(root)) return false;
|
|
33
|
+
if (current?.root !== root) current = { root, controls: new TaskScheduleControls(createTaskScheduleRepository({
|
|
34
|
+
root, backupRoot: join(configDir(), 'task-schedule-checkpoints'),
|
|
35
|
+
})) };
|
|
36
|
+
return current.controls.handle(request, { controller: JSON.stringify([client.room, peer]), sessions,
|
|
37
|
+
send: result => sendSessionPayload(client, result, request.sessionId, 'phone', {
|
|
38
|
+
peerPublicKey: peer, reliable: false, urgent: true, ttlMs: 30_000,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
}
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.116
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.116\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.116";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.116",
|
|
4
4
|
"description": "Commercial computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
40
40
|
"express": "^5.2.1",
|
|
41
41
|
"qrcode": "^1.5.4",
|
|
42
|
+
"smol-toml": "1.9.1",
|
|
42
43
|
"tsx": "^4.23.4",
|
|
43
44
|
"tweetnacl": "^1.0.3",
|
|
44
45
|
"ws": "^8.18.0",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { DeviceManagementRequest, DeviceManagementResult } from './messages/device/management';
|
|
4
4
|
export * from './messages/device/management';
|
|
5
|
+
import { TaskSchedulesRequest, TaskSchedulesResult } from './task-schedules';
|
|
6
|
+
export * from './task-schedules';
|
|
5
7
|
import { ProjectTransportSet, ProjectTransportAck } from './task-network';
|
|
6
8
|
export * from './task-network';
|
|
7
9
|
import { TaskTransferContinue, TaskTransferContinueResult } from "./task-transfer";
|
|
@@ -117,6 +119,7 @@ export const MachineHeartbeat = z.object({
|
|
|
117
119
|
machine: z.string().catch("machine"),
|
|
118
120
|
createdAt: z.number().catch(() => Date.now()),
|
|
119
121
|
taskQueueImmediate: z.boolean().optional(),
|
|
122
|
+
taskScheduleControls: z.boolean().optional(),
|
|
120
123
|
});
|
|
121
124
|
export type MachineHeartbeat = z.infer<typeof MachineHeartbeat>;
|
|
122
125
|
|
|
@@ -124,6 +127,8 @@ export type MachineHeartbeat = z.infer<typeof MachineHeartbeat>;
|
|
|
124
127
|
// ZodEffects rather than plain objects. A regular union preserves those checks
|
|
125
128
|
// while keeping the same strict `type` discriminator on every member.
|
|
126
129
|
export const Payload = z.union([
|
|
130
|
+
TaskSchedulesRequest,
|
|
131
|
+
TaskSchedulesResult,
|
|
127
132
|
DeviceManagementRequest,
|
|
128
133
|
DeviceManagementResult,
|
|
129
134
|
ApprovalRequest,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Task schedule protocol
|
|
2
|
+
|
|
3
|
+
Public entry: `index.ts`. Strict encrypted messages query or change existing native
|
|
4
|
+
automations for an admitted chat. Names, instructions, recurrence and enabled state
|
|
5
|
+
are separate from provider execution, delivery queues and capability policy.
|
|
6
|
+
Results distinguish an applied change, an unconfirmed native state and failure.
|
|
7
|
+
|
|
8
|
+
License: first-party code is under the GrantTap Commercial Source License in the repository LICENSE.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const identifier = z.string().min(1).max(256);
|
|
4
|
+
export const TaskSchedulePatch = z.object({
|
|
5
|
+
name: z.string().trim().min(1).max(160).optional(),
|
|
6
|
+
prompt: z.string().min(1).max(8192).refine(value => value.trim().length > 0).optional(),
|
|
7
|
+
rrule: z.string().trim().min(1).max(1024).optional(),
|
|
8
|
+
status: z.enum(['ACTIVE', 'PAUSED']).optional(),
|
|
9
|
+
}).strict().refine(value => Object.keys(value).length > 0, 'An edit must change a supported field');
|
|
10
|
+
export type TaskSchedulePatch = z.infer<typeof TaskSchedulePatch>;
|
|
11
|
+
export const TaskSchedule = z.object({
|
|
12
|
+
id: identifier, sessionId: identifier, name: z.string().min(1).max(160),
|
|
13
|
+
prompt: z.string().max(8192), rrule: z.string().max(1024),
|
|
14
|
+
status: z.enum(['ACTIVE', 'PAUSED']), revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
15
|
+
editable: z.boolean(), confirmation: z.enum(['confirmed', 'waiting', 'unavailable']),
|
|
16
|
+
reason: z.string().max(240).optional(),
|
|
17
|
+
}).strict();
|
|
18
|
+
export type TaskSchedule = z.infer<typeof TaskSchedule>;
|
|
19
|
+
export const TaskSchedulesRequest = z.object({
|
|
20
|
+
type: z.literal('task.schedules'), requestId: identifier, sessionId: identifier,
|
|
21
|
+
createdAt: z.number().finite(),
|
|
22
|
+
change: z.object({ id: identifier, revision: z.string().regex(/^[a-f0-9]{64}$/), patch: TaskSchedulePatch }).strict().optional(),
|
|
23
|
+
}).strict();
|
|
24
|
+
export type TaskSchedulesRequest = z.infer<typeof TaskSchedulesRequest>;
|
|
25
|
+
export const TaskSchedulesResult = z.object({
|
|
26
|
+
type: z.literal('task.schedules.result'), requestId: identifier, sessionId: identifier,
|
|
27
|
+
phase: z.enum(['completed', 'waiting', 'failed', 'unavailable']),
|
|
28
|
+
schedules: z.array(TaskSchedule).max(32), truncated: z.boolean(),
|
|
29
|
+
timeZone: z.string().max(100), message: z.string().max(240).optional(), createdAt: z.number().finite(),
|
|
30
|
+
}).strict().refine(value => new Set(value.schedules.map(row => row.id)).size === value.schedules.length
|
|
31
|
+
&& new TextEncoder().encode(JSON.stringify(value)).length <= 65_536);
|
|
32
|
+
export type TaskSchedulesResult = z.infer<typeof TaskSchedulesResult>;
|