@myagentroam/node 0.9.122 → 0.9.125
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/dist/codex-app-server.js +6 -79
- package/dist/connector.js +217 -6
- package/dist/database.js +2 -1
- package/dist/migrations/v014.js +107 -0
- package/dist/runner/abstract-runner.js +11 -5
- package/dist/runner/claude-code-runner.js +3 -0
- package/dist/runner/codex/conversation-parser.js +142 -2
- package/dist/runner/codex/managed-run-controller.js +26 -18
- package/dist/runner/codex-runner.js +127 -57
- package/dist/runner/opencode-runner.js +14 -12
- package/dist/runtime-state.js +6 -1
- package/dist/service/continuous-run-coordinator.js +8 -2
- package/dist/service/conversation-history-service.js +51 -10
- package/dist/service/managed-runner-session-service.js +2 -1
- package/dist/service/native-session-projection-service.js +3 -1
- package/dist/service/node-control-message-service.js +2 -0
- package/dist/service/run-event-service.js +18 -2
- package/dist/service/runner-interaction-service.js +9 -8
- package/dist/service/scheduled-run-authorization-control.js +72 -0
- package/dist/service/scheduled-session-coordinator.js +95 -0
- package/dist/service/scheduled-session-recurrence.js +72 -0
- package/dist/service/scheduled-session-store.js +234 -0
- package/dist/service/scheduled-task-mcp-server.js +179 -0
- package/dist/service/scheduled-task-mcp-stdio.js +63 -0
- package/dist/service/session-catalog-service.js +3 -1
- package/dist/service/session-lifecycle-service.js +6 -0
- package/dist/service/session-message-service.js +25 -2
- package/dist/service/session-presentation-service.js +19 -2
- package/dist/service/session-schedule-service.js +153 -0
- package/dist/util/runner-native-session-parsers.js +19 -11
- package/package.json +4 -4
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { assertScheduleTimezone, nextScheduledAt, parseScheduledCron } from './scheduled-session-recurrence.js';
|
|
3
|
+
export class ScheduledSessionStore {
|
|
4
|
+
database;
|
|
5
|
+
now;
|
|
6
|
+
constructor(database, now = Date.now) {
|
|
7
|
+
this.database = database;
|
|
8
|
+
this.now = now;
|
|
9
|
+
}
|
|
10
|
+
get(sessionId) {
|
|
11
|
+
const row = this.database
|
|
12
|
+
.prepare('SELECT * FROM scheduled_session_tasks WHERE session_id=?')
|
|
13
|
+
.get(sessionId);
|
|
14
|
+
return row === undefined ? undefined : taskFromRow(row);
|
|
15
|
+
}
|
|
16
|
+
getById(taskId) {
|
|
17
|
+
const row = this.database
|
|
18
|
+
.prepare('SELECT * FROM scheduled_session_tasks WHERE id=?')
|
|
19
|
+
.get(taskId);
|
|
20
|
+
return row === undefined ? undefined : taskFromRow(row);
|
|
21
|
+
}
|
|
22
|
+
listDue(at = this.now()) {
|
|
23
|
+
return this.database
|
|
24
|
+
.prepare('SELECT * FROM scheduled_session_tasks WHERE enabled=1 AND next_run_at<=? ORDER BY next_run_at,id')
|
|
25
|
+
.all(at).map(taskFromRow);
|
|
26
|
+
}
|
|
27
|
+
nextDueAt() {
|
|
28
|
+
const row = this.database
|
|
29
|
+
.prepare('SELECT MIN(next_run_at) AS value FROM scheduled_session_tasks WHERE enabled=1')
|
|
30
|
+
.get();
|
|
31
|
+
return typeof row['value'] === 'number' ? row['value'] : null;
|
|
32
|
+
}
|
|
33
|
+
create(input) {
|
|
34
|
+
validate(input.objective, input.cron, input.timezone);
|
|
35
|
+
if (!Number.isSafeInteger(input.ownerUserId) || input.ownerUserId < 1)
|
|
36
|
+
throw new Error('SCHEDULE_OWNER_INVALID');
|
|
37
|
+
const now = this.now();
|
|
38
|
+
const task = {
|
|
39
|
+
sessionId: input.sessionId,
|
|
40
|
+
workspaceId: input.workspaceId,
|
|
41
|
+
runner: input.runner,
|
|
42
|
+
ownerUserId: input.ownerUserId,
|
|
43
|
+
cron: input.cron,
|
|
44
|
+
timezone: input.timezone,
|
|
45
|
+
objective: input.objective.trim(),
|
|
46
|
+
id: randomUUID(),
|
|
47
|
+
enabled: input.enabled !== false,
|
|
48
|
+
revision: 1,
|
|
49
|
+
nextRunAt: input.enabled === false ? null : nextScheduledAt(input.cron, input.timezone, now),
|
|
50
|
+
createdAt: now,
|
|
51
|
+
updatedAt: now
|
|
52
|
+
};
|
|
53
|
+
this.database
|
|
54
|
+
.prepare(`INSERT INTO scheduled_session_tasks
|
|
55
|
+
(id,session_id,workspace_id,runner,owner_user_id,objective,cron,timezone,enabled,revision,next_run_at,created_at,updated_at)
|
|
56
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
57
|
+
.run(task.id, task.sessionId, task.workspaceId, task.runner, task.ownerUserId, task.objective, task.cron, task.timezone, task.enabled ? 1 : 0, 1, task.nextRunAt, now, now);
|
|
58
|
+
return task;
|
|
59
|
+
}
|
|
60
|
+
update(sessionId, expectedRevision, patch) {
|
|
61
|
+
const current = this.get(sessionId);
|
|
62
|
+
if (current === undefined)
|
|
63
|
+
throw new Error('SCHEDULE_NOT_FOUND');
|
|
64
|
+
if (current.revision !== expectedRevision)
|
|
65
|
+
throw new Error('SCHEDULE_REVISION_CONFLICT');
|
|
66
|
+
const objective = patch.objective ?? current.objective;
|
|
67
|
+
const cron = patch.cron ?? current.cron;
|
|
68
|
+
const timezone = patch.timezone ?? current.timezone;
|
|
69
|
+
validate(objective, cron, timezone);
|
|
70
|
+
const enabled = patch.enabled ?? current.enabled;
|
|
71
|
+
const nextRunAt = enabled
|
|
72
|
+
? patch.enabled === true || patch.cron !== undefined || patch.timezone !== undefined
|
|
73
|
+
? nextScheduledAt(cron, timezone, this.now())
|
|
74
|
+
: current.nextRunAt
|
|
75
|
+
: null;
|
|
76
|
+
const now = this.now();
|
|
77
|
+
const result = this.database
|
|
78
|
+
.prepare(`UPDATE scheduled_session_tasks SET objective=?,cron=?,timezone=?,enabled=?,revision=?,next_run_at=?,updated_at=?
|
|
79
|
+
WHERE session_id=? AND revision=?`)
|
|
80
|
+
.run(objective.trim(), cron, timezone, enabled ? 1 : 0, expectedRevision + 1, nextRunAt, now, sessionId, expectedRevision);
|
|
81
|
+
if (result.changes !== 1)
|
|
82
|
+
throw new Error('SCHEDULE_REVISION_CONFLICT');
|
|
83
|
+
this.database
|
|
84
|
+
.prepare(`UPDATE scheduled_session_proposals SET status='STALE',resolved_at=?
|
|
85
|
+
WHERE task_id=? AND status='PENDING' AND base_revision<?`)
|
|
86
|
+
.run(now, current.id, expectedRevision + 1);
|
|
87
|
+
this.database
|
|
88
|
+
.prepare("DELETE FROM scheduled_session_proposals WHERE task_id=? AND status='STALE'")
|
|
89
|
+
.run(current.id);
|
|
90
|
+
return this.get(sessionId);
|
|
91
|
+
}
|
|
92
|
+
remove(sessionId, expectedRevision) {
|
|
93
|
+
const result = this.database
|
|
94
|
+
.prepare('DELETE FROM scheduled_session_tasks WHERE session_id=? AND revision=?')
|
|
95
|
+
.run(sessionId, expectedRevision);
|
|
96
|
+
if (result.changes !== 1)
|
|
97
|
+
throw new Error('SCHEDULE_REVISION_CONFLICT');
|
|
98
|
+
}
|
|
99
|
+
claim(sessionId, dueAt, expectedRevision) {
|
|
100
|
+
const current = this.get(sessionId);
|
|
101
|
+
if (current === undefined ||
|
|
102
|
+
!current.enabled ||
|
|
103
|
+
current.revision !== expectedRevision ||
|
|
104
|
+
current.nextRunAt !== dueAt)
|
|
105
|
+
return undefined;
|
|
106
|
+
const next = nextScheduledAt(current.cron, current.timezone, Math.max(this.now(), dueAt));
|
|
107
|
+
const id = `${current.id}:${dueAt}`;
|
|
108
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
109
|
+
try {
|
|
110
|
+
const result = this.database
|
|
111
|
+
.prepare('UPDATE scheduled_session_tasks SET next_run_at=?,updated_at=? WHERE id=? AND revision=? AND next_run_at=?')
|
|
112
|
+
.run(next, this.now(), current.id, expectedRevision, dueAt);
|
|
113
|
+
if (result.changes !== 1) {
|
|
114
|
+
this.database.exec('ROLLBACK');
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
this.database
|
|
118
|
+
.prepare(`INSERT INTO scheduled_session_occurrences
|
|
119
|
+
(id,task_id,due_at,status,created_at,updated_at) VALUES (?,?,?,'CLAIMED',?,?)`)
|
|
120
|
+
.run(id, current.id, dueAt, this.now(), this.now());
|
|
121
|
+
this.database.exec('COMMIT');
|
|
122
|
+
return id;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
this.database.exec('ROLLBACK');
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finish(occurrenceId, status, code, runId) {
|
|
130
|
+
const result = this.database
|
|
131
|
+
.prepare(`UPDATE scheduled_session_occurrences SET status=?,code=?,run_id=?,updated_at=?
|
|
132
|
+
WHERE id=? AND status IN ('CLAIMED','SUBMITTED')`)
|
|
133
|
+
.run(status, code ?? null, runId ?? null, this.now(), occurrenceId);
|
|
134
|
+
if (result.changes === 0 || status === 'SUBMITTED')
|
|
135
|
+
return;
|
|
136
|
+
this.database
|
|
137
|
+
.prepare(`DELETE FROM scheduled_session_occurrences
|
|
138
|
+
WHERE task_id=(SELECT task_id FROM scheduled_session_occurrences WHERE id=?)
|
|
139
|
+
AND status NOT IN ('CLAIMED','SUBMITTED')
|
|
140
|
+
AND id NOT IN (SELECT id FROM scheduled_session_occurrences
|
|
141
|
+
WHERE task_id=(SELECT task_id FROM scheduled_session_occurrences WHERE id=?)
|
|
142
|
+
AND status NOT IN ('CLAIMED','SUBMITTED') ORDER BY due_at DESC LIMIT 100)`)
|
|
143
|
+
.run(occurrenceId, occurrenceId);
|
|
144
|
+
}
|
|
145
|
+
recent(taskId, limit = 20) {
|
|
146
|
+
return this.database
|
|
147
|
+
.prepare('SELECT * FROM scheduled_session_occurrences WHERE task_id=? ORDER BY due_at DESC LIMIT ?')
|
|
148
|
+
.all(taskId, Math.max(1, Math.min(limit, 100)));
|
|
149
|
+
}
|
|
150
|
+
pendingProposals(taskId) {
|
|
151
|
+
return this.database
|
|
152
|
+
.prepare(`SELECT * FROM scheduled_session_proposals
|
|
153
|
+
WHERE task_id=? AND status='PENDING' ORDER BY created_at DESC LIMIT 50`)
|
|
154
|
+
.all(taskId);
|
|
155
|
+
}
|
|
156
|
+
propose(task, actorUserId, patch) {
|
|
157
|
+
if (!Number.isSafeInteger(actorUserId) || actorUserId < 1 || actorUserId === task.ownerUserId)
|
|
158
|
+
throw new Error('SCHEDULE_PROPOSAL_INVALID');
|
|
159
|
+
if (patch.objective === undefined && patch.cron === undefined && patch.timezone === undefined)
|
|
160
|
+
throw new Error('SCHEDULE_PROPOSAL_INVALID');
|
|
161
|
+
validate(patch.objective ?? task.objective, patch.cron ?? task.cron, patch.timezone ?? task.timezone);
|
|
162
|
+
const pending = this.database
|
|
163
|
+
.prepare("SELECT COUNT(*) AS count FROM scheduled_session_proposals WHERE task_id=? AND status='PENDING'")
|
|
164
|
+
.get(task.id);
|
|
165
|
+
if (pending.count >= 50)
|
|
166
|
+
throw new Error('SCHEDULE_PROPOSAL_LIMIT');
|
|
167
|
+
const id = randomUUID();
|
|
168
|
+
this.database
|
|
169
|
+
.prepare(`INSERT INTO scheduled_session_proposals
|
|
170
|
+
(id,task_id,proposed_by_user_id,base_revision,objective,cron,timezone,status,created_at)
|
|
171
|
+
VALUES (?,?,?,?,?,?,?,'PENDING',?)`)
|
|
172
|
+
.run(id, task.id, actorUserId, task.revision, patch.objective ?? null, patch.cron ?? null, patch.timezone ?? null, this.now());
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
resolveProposal(sessionId, proposalId, ownerUserId, accept) {
|
|
176
|
+
const task = this.get(sessionId);
|
|
177
|
+
if (task === undefined)
|
|
178
|
+
throw new Error('SCHEDULE_NOT_FOUND');
|
|
179
|
+
if (task.ownerUserId !== ownerUserId)
|
|
180
|
+
throw new Error('SCHEDULE_OWNER_REQUIRED');
|
|
181
|
+
const proposal = this.database
|
|
182
|
+
.prepare('SELECT * FROM scheduled_session_proposals WHERE id=? AND task_id=?')
|
|
183
|
+
.get(proposalId, task.id);
|
|
184
|
+
if (proposal === undefined || proposal['status'] !== 'PENDING')
|
|
185
|
+
throw new Error('SCHEDULE_PROPOSAL_NOT_FOUND');
|
|
186
|
+
if (proposal['base_revision'] !== task.revision)
|
|
187
|
+
throw new Error('SCHEDULE_REVISION_CONFLICT');
|
|
188
|
+
this.database.exec('BEGIN IMMEDIATE');
|
|
189
|
+
try {
|
|
190
|
+
const updated = accept
|
|
191
|
+
? this.update(sessionId, task.revision, {
|
|
192
|
+
...(proposal['objective'] === null
|
|
193
|
+
? {}
|
|
194
|
+
: { objective: proposal['objective'] }),
|
|
195
|
+
...(proposal['cron'] === null ? {} : { cron: proposal['cron'] }),
|
|
196
|
+
...(proposal['timezone'] === null ? {} : { timezone: proposal['timezone'] })
|
|
197
|
+
})
|
|
198
|
+
: task;
|
|
199
|
+
this.database
|
|
200
|
+
.prepare('UPDATE scheduled_session_proposals SET status=?,resolved_at=? WHERE id=?')
|
|
201
|
+
.run(accept ? 'ACCEPTED' : 'REJECTED', this.now(), proposalId);
|
|
202
|
+
this.database.prepare('DELETE FROM scheduled_session_proposals WHERE id=?').run(proposalId);
|
|
203
|
+
this.database.exec('COMMIT');
|
|
204
|
+
return updated;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
this.database.exec('ROLLBACK');
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function validate(objective, cron, timezone) {
|
|
213
|
+
if (objective.trim().length < 1 || objective.trim().length > 20_000)
|
|
214
|
+
throw new Error('SCHEDULE_OBJECTIVE_INVALID');
|
|
215
|
+
parseScheduledCron(cron);
|
|
216
|
+
assertScheduleTimezone(timezone);
|
|
217
|
+
}
|
|
218
|
+
function taskFromRow(row) {
|
|
219
|
+
return {
|
|
220
|
+
id: row['id'],
|
|
221
|
+
sessionId: row['session_id'],
|
|
222
|
+
workspaceId: row['workspace_id'],
|
|
223
|
+
runner: row['runner'],
|
|
224
|
+
ownerUserId: Number(row['owner_user_id']),
|
|
225
|
+
objective: row['objective'],
|
|
226
|
+
cron: row['cron'],
|
|
227
|
+
timezone: row['timezone'],
|
|
228
|
+
enabled: row['enabled'] === 1,
|
|
229
|
+
revision: Number(row['revision']),
|
|
230
|
+
nextRunAt: row['next_run_at'] === null ? null : Number(row['next_run_at']),
|
|
231
|
+
createdAt: Number(row['created_at']),
|
|
232
|
+
updatedAt: Number(row['updated_at'])
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { unlink } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
export class ScheduledTaskMcpServer {
|
|
9
|
+
store;
|
|
10
|
+
changed;
|
|
11
|
+
proposed;
|
|
12
|
+
create;
|
|
13
|
+
resolveSessionId;
|
|
14
|
+
#capabilities = new Map();
|
|
15
|
+
#sockets = new Set();
|
|
16
|
+
#endpoint = process.platform === 'win32'
|
|
17
|
+
? `\\\\.\\pipe\\mar-schedule-${process.pid}-${randomUUID()}`
|
|
18
|
+
: join(tmpdir(), `mar-schedule-${process.pid}-${randomUUID()}.sock`);
|
|
19
|
+
#listening;
|
|
20
|
+
constructor(store, changed, proposed, create, resolveSessionId) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
this.changed = changed;
|
|
23
|
+
this.proposed = proposed;
|
|
24
|
+
this.create = create;
|
|
25
|
+
this.resolveSessionId = resolveSessionId;
|
|
26
|
+
}
|
|
27
|
+
async installation(capability) {
|
|
28
|
+
await this.#listen();
|
|
29
|
+
const token = randomBytes(32).toString('base64url');
|
|
30
|
+
const secretName = `MAR_SCHEDULE_MCP_${randomBytes(16).toString('hex').toUpperCase()}`;
|
|
31
|
+
this.#capabilities.set(token, capability);
|
|
32
|
+
return {
|
|
33
|
+
installation: {
|
|
34
|
+
__marScheduled: true,
|
|
35
|
+
serverName: 'mar_schedule',
|
|
36
|
+
enabled: true,
|
|
37
|
+
command: process.execPath,
|
|
38
|
+
args: scheduledMcpStdioArgs(),
|
|
39
|
+
endpoint: this.#endpoint,
|
|
40
|
+
secretName,
|
|
41
|
+
readOnly: capability.readonly
|
|
42
|
+
},
|
|
43
|
+
secrets: { [secretName]: token }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
revoke(executionId) {
|
|
47
|
+
for (const [token, capability] of this.#capabilities)
|
|
48
|
+
if (capability.executionId === executionId)
|
|
49
|
+
this.#capabilities.delete(token);
|
|
50
|
+
}
|
|
51
|
+
async close() {
|
|
52
|
+
if (this.#listening === undefined)
|
|
53
|
+
return;
|
|
54
|
+
const server = await this.#listening;
|
|
55
|
+
this.#capabilities.clear();
|
|
56
|
+
const closed = new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))));
|
|
57
|
+
for (const socket of this.#sockets)
|
|
58
|
+
socket.destroy();
|
|
59
|
+
await closed;
|
|
60
|
+
if (process.platform !== 'win32')
|
|
61
|
+
await unlink(this.#endpoint).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
#listen() {
|
|
64
|
+
this.#listening ??= new Promise((resolve, reject) => {
|
|
65
|
+
const server = createServer((socket) => this.#handle(socket));
|
|
66
|
+
server.once('error', reject);
|
|
67
|
+
server.listen(this.#endpoint, () => resolve(server));
|
|
68
|
+
});
|
|
69
|
+
return this.#listening;
|
|
70
|
+
}
|
|
71
|
+
#handle(socket) {
|
|
72
|
+
this.#sockets.add(socket);
|
|
73
|
+
socket.once('close', () => this.#sockets.delete(socket));
|
|
74
|
+
let body = '';
|
|
75
|
+
socket.setEncoding('utf8');
|
|
76
|
+
socket.on('data', (chunk) => {
|
|
77
|
+
body += chunk;
|
|
78
|
+
if (body.length > 64_000) {
|
|
79
|
+
socket.destroy();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (body.includes('\n'))
|
|
83
|
+
void this.#respond(socket, body.slice(0, body.indexOf('\n')));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async #respond(socket, body) {
|
|
87
|
+
try {
|
|
88
|
+
const request = JSON.parse(body);
|
|
89
|
+
const capability = typeof request.token === 'string' ? this.#capabilities.get(request.token) : undefined;
|
|
90
|
+
if (capability === undefined)
|
|
91
|
+
throw new Error('SCHEDULE_CAPABILITY_INVALID');
|
|
92
|
+
const sessionId = this.resolveSessionId?.(capability.sessionId) ?? capability.sessionId;
|
|
93
|
+
const task = this.store().get(sessionId);
|
|
94
|
+
if ((task === undefined && capability.taskId !== undefined) ||
|
|
95
|
+
(task !== undefined &&
|
|
96
|
+
(task.id !== capability.taskId || task.revision !== capability.revision)))
|
|
97
|
+
throw new Error('SCHEDULE_CAPABILITY_STALE');
|
|
98
|
+
if (request.tool === 'mar_get_scheduled_task') {
|
|
99
|
+
socket.end(`${JSON.stringify({ result: task ?? null })}\n`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (request.tool === 'mar_create_scheduled_task') {
|
|
103
|
+
if (capability.readonly)
|
|
104
|
+
throw new Error('SCHEDULE_READ_ONLY');
|
|
105
|
+
if (task !== undefined)
|
|
106
|
+
throw new Error('SCHEDULE_ALREADY_EXISTS');
|
|
107
|
+
const args = record(request.arguments);
|
|
108
|
+
if (typeof args.objective !== 'string' ||
|
|
109
|
+
typeof args.cron !== 'string' ||
|
|
110
|
+
capability.workspaceId === undefined ||
|
|
111
|
+
this.create === undefined)
|
|
112
|
+
throw new Error('SCHEDULE_INVALID');
|
|
113
|
+
const created = await this.create({
|
|
114
|
+
sessionId: capability.sessionId,
|
|
115
|
+
workspaceId: capability.workspaceId,
|
|
116
|
+
actorUserId: capability.actorUserId,
|
|
117
|
+
objective: args.objective,
|
|
118
|
+
cron: args.cron
|
|
119
|
+
});
|
|
120
|
+
this.#capabilities.set(request.token, {
|
|
121
|
+
...capability,
|
|
122
|
+
sessionId: created.sessionId,
|
|
123
|
+
taskId: created.id,
|
|
124
|
+
revision: created.revision
|
|
125
|
+
});
|
|
126
|
+
socket.end(`${JSON.stringify({ result: created })}\n`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (request.tool !== 'mar_update_scheduled_task')
|
|
130
|
+
throw new Error('SCHEDULE_TOOL_INVALID');
|
|
131
|
+
if (capability.readonly)
|
|
132
|
+
throw new Error('SCHEDULE_READ_ONLY');
|
|
133
|
+
if (task === undefined)
|
|
134
|
+
throw new Error('SCHEDULE_NOT_FOUND');
|
|
135
|
+
const args = record(request.arguments);
|
|
136
|
+
const objective = args.objective;
|
|
137
|
+
const cron = args.cron;
|
|
138
|
+
if ((objective !== undefined && typeof objective !== 'string') ||
|
|
139
|
+
(cron !== undefined && typeof cron !== 'string') ||
|
|
140
|
+
(objective === undefined && cron === undefined))
|
|
141
|
+
throw new Error('SCHEDULE_UPDATE_INVALID');
|
|
142
|
+
const patch = {
|
|
143
|
+
...(objective === undefined ? {} : { objective: objective }),
|
|
144
|
+
...(cron === undefined ? {} : { cron: cron })
|
|
145
|
+
};
|
|
146
|
+
if (capability.actorUserId === task.ownerUserId) {
|
|
147
|
+
const updated = this.store().update(task.sessionId, task.revision, patch);
|
|
148
|
+
this.#capabilities.set(request.token, {
|
|
149
|
+
...capability,
|
|
150
|
+
revision: updated.revision
|
|
151
|
+
});
|
|
152
|
+
this.changed(task.sessionId);
|
|
153
|
+
socket.end(`${JSON.stringify({ result: updated })}\n`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const proposalId = this.store().propose(task, capability.actorUserId, patch);
|
|
157
|
+
this.proposed(task.sessionId, proposalId);
|
|
158
|
+
socket.end(`${JSON.stringify({ result: { proposalId, status: 'PENDING' } })}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
socket.end(`${JSON.stringify({ error: error instanceof Error ? error.message : 'SCHEDULE_IPC_FAILED' })}\n`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function record(value) {
|
|
167
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
168
|
+
? value
|
|
169
|
+
: {};
|
|
170
|
+
}
|
|
171
|
+
function scheduledMcpStdioArgs() {
|
|
172
|
+
const javascript = fileURLToPath(new URL('./scheduled-task-mcp-stdio.js', import.meta.url));
|
|
173
|
+
if (existsSync(javascript))
|
|
174
|
+
return [javascript];
|
|
175
|
+
const typescript = fileURLToPath(new URL('./scheduled-task-mcp-stdio.ts', import.meta.url));
|
|
176
|
+
if (!existsSync(typescript))
|
|
177
|
+
throw new Error('SCHEDULE_MCP_STDIO_ENTRY_UNAVAILABLE');
|
|
178
|
+
return ['--import', import.meta.resolve('tsx'), typescript];
|
|
179
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
const endpoint = process.env['MAR_SCHEDULE_MCP_ENDPOINT'];
|
|
6
|
+
const token = process.env['MAR_SCHEDULE_MCP_TOKEN'];
|
|
7
|
+
if (endpoint === undefined || token === undefined)
|
|
8
|
+
throw new Error('SCHEDULE_CAPABILITY_INVALID');
|
|
9
|
+
const server = new McpServer({ name: 'mar_schedule', version: '1.0.0' });
|
|
10
|
+
server.registerTool('mar_get_scheduled_task', {
|
|
11
|
+
description: 'Read the MAR scheduled task for this MAR-managed Workspace conversation, or null if none exists.'
|
|
12
|
+
}, async () => result(await request('mar_get_scheduled_task', {})));
|
|
13
|
+
if (process.env['MAR_SCHEDULE_MCP_READ_ONLY'] !== '1') {
|
|
14
|
+
server.registerTool('mar_create_scheduled_task', {
|
|
15
|
+
description: 'Create a MAR scheduled task for this MAR-managed Workspace conversation. MAR runs it in this conversation using the connected Node time zone.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
objective: z.string().min(1).max(20_000).describe('Prompt to run at each trigger.'),
|
|
18
|
+
cron: z
|
|
19
|
+
.string()
|
|
20
|
+
.min(9)
|
|
21
|
+
.max(64)
|
|
22
|
+
.describe('Five-field cron expression interpreted in the connected MAR Node time zone.')
|
|
23
|
+
}
|
|
24
|
+
}, async (args) => result(await request('mar_create_scheduled_task', args)));
|
|
25
|
+
server.registerTool('mar_update_scheduled_task', {
|
|
26
|
+
description: 'Update the MAR scheduled task for this MAR-managed Workspace conversation. Its cron expression uses the connected Node time zone.',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
objective: z.string().min(1).max(20_000).describe('Replacement prompt.').optional(),
|
|
29
|
+
cron: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(9)
|
|
32
|
+
.max(64)
|
|
33
|
+
.describe('Replacement five-field cron expression in the connected MAR Node time zone.')
|
|
34
|
+
.optional()
|
|
35
|
+
}
|
|
36
|
+
}, async (args) => result(await request('mar_update_scheduled_task', args)));
|
|
37
|
+
}
|
|
38
|
+
await server.connect(new StdioServerTransport());
|
|
39
|
+
function request(tool, args) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const socket = connect(endpoint);
|
|
42
|
+
let body = '';
|
|
43
|
+
socket.setEncoding('utf8');
|
|
44
|
+
socket.once('connect', () => socket.end(`${JSON.stringify({ token, tool, arguments: args })}\n`));
|
|
45
|
+
socket.on('data', (chunk) => (body += chunk));
|
|
46
|
+
socket.once('error', reject);
|
|
47
|
+
socket.once('end', () => {
|
|
48
|
+
try {
|
|
49
|
+
const payload = JSON.parse(body);
|
|
50
|
+
if (payload.error !== undefined)
|
|
51
|
+
reject(new Error(payload.error));
|
|
52
|
+
else
|
|
53
|
+
resolve(payload.result);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
reject(error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function result(value) {
|
|
62
|
+
return { content: [{ type: 'text', text: JSON.stringify(value) }] };
|
|
63
|
+
}
|
|
@@ -38,7 +38,9 @@ export class SessionCatalogService {
|
|
|
38
38
|
source?.runnerTitle === undefined ||
|
|
39
39
|
source.runnerTitle === null
|
|
40
40
|
? session
|
|
41
|
-
:
|
|
41
|
+
: source.titleSource === 'AUTO'
|
|
42
|
+
? this.options.runtime.setTemporaryTitleIfMissing(session.id, source.runnerTitle)
|
|
43
|
+
: this.options.runtime.setRunnerTitleFromNative(session.id, source.runnerTitle);
|
|
42
44
|
return this.options.present(source === undefined
|
|
43
45
|
? synchronized
|
|
44
46
|
: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validSessionEffort, validSessionOption, validSessionTitle } from '../util/node-operation-parsers.js';
|
|
2
2
|
import { isPlainRecord } from '../util/node-operation-parsers.js';
|
|
3
3
|
import { parseSecretEnvironment } from '../util/secret-environment.js';
|
|
4
|
+
import { ScheduledSessionStore } from './scheduled-session-store.js';
|
|
4
5
|
export class SessionLifecycleService {
|
|
5
6
|
options;
|
|
6
7
|
starts = new Map();
|
|
@@ -34,6 +35,11 @@ export class SessionLifecycleService {
|
|
|
34
35
|
(await this.options.projection.createMetadataProjection(input.sessionId));
|
|
35
36
|
if (current === undefined)
|
|
36
37
|
throw new Error('SESSION_NOT_FOUND');
|
|
38
|
+
if (input.pinned === false) {
|
|
39
|
+
const task = new ScheduledSessionStore(this.options.database().raw).get(current.externalSessionId ?? current.id);
|
|
40
|
+
if (task?.enabled === true)
|
|
41
|
+
throw new Error('SCHEDULE_PIN_REQUIRED');
|
|
42
|
+
}
|
|
37
43
|
const nativeCodexRename = this.options.runners.require(current.runner).usesRunnerTitleForRename() &&
|
|
38
44
|
current.externalSessionId !== null &&
|
|
39
45
|
input.customTitle !== undefined;
|
|
@@ -5,6 +5,7 @@ import { personalInstructionsForRunner } from '../util/personal-instructions.js'
|
|
|
5
5
|
import { safeErrorCode } from '../util/safe-error.js';
|
|
6
6
|
import { publicGoalStartText } from './goal-runtime.js';
|
|
7
7
|
const INTERNAL_GOAL = Symbol('internalGoal');
|
|
8
|
+
const INTERNAL_SCHEDULE = Symbol('internalSchedule');
|
|
8
9
|
export class SessionMessageService {
|
|
9
10
|
options;
|
|
10
11
|
pendingMessages = new Map();
|
|
@@ -37,6 +38,22 @@ export class SessionMessageService {
|
|
|
37
38
|
marAgentRuntimeScopeId: input.marAgentRuntimeScopeId
|
|
38
39
|
});
|
|
39
40
|
}
|
|
41
|
+
submitScheduled(input) {
|
|
42
|
+
return this.message({
|
|
43
|
+
[INTERNAL_SCHEDULE]: true,
|
|
44
|
+
sessionId: input.sessionId,
|
|
45
|
+
clientMessageId: input.occurrenceId,
|
|
46
|
+
content: input.objective,
|
|
47
|
+
__runnerContent: `Scheduled task execution:\n${input.objective}`,
|
|
48
|
+
__requestUserId: input.initiatedByUserId,
|
|
49
|
+
deliveryIntent: 'SEND',
|
|
50
|
+
secretEnvironment: input.secretEnvironment,
|
|
51
|
+
personalInstructions: input.personalInstructions,
|
|
52
|
+
marAgentModel: input.marAgentModel,
|
|
53
|
+
marAgentModels: input.marAgentModels,
|
|
54
|
+
marAgentRuntimeScopeId: input.marAgentRuntimeScopeId
|
|
55
|
+
});
|
|
56
|
+
}
|
|
40
57
|
async message(input) {
|
|
41
58
|
if (typeof input.sessionId !== 'string' ||
|
|
42
59
|
typeof input.clientMessageId !== 'string' ||
|
|
@@ -47,7 +64,10 @@ export class SessionMessageService {
|
|
|
47
64
|
const clientMessageId = input.clientMessageId;
|
|
48
65
|
const content = input.content;
|
|
49
66
|
const internalGoal = input[INTERNAL_GOAL] === true;
|
|
50
|
-
const
|
|
67
|
+
const internalSchedule = input[INTERNAL_SCHEDULE] === true;
|
|
68
|
+
const internalRunnerContent = (internalGoal || internalSchedule) && typeof input.__runnerContent === 'string'
|
|
69
|
+
? input.__runnerContent
|
|
70
|
+
: content;
|
|
51
71
|
const inlineAttachments = parseMessageAttachments(input.attachments);
|
|
52
72
|
const secretEnvironment = parseSecretEnvironment(input.secretEnvironment);
|
|
53
73
|
const attachmentIds = parseComposerAttachmentIds(input.attachmentIds);
|
|
@@ -80,7 +100,7 @@ export class SessionMessageService {
|
|
|
80
100
|
idempotent: true,
|
|
81
101
|
auditReceipt: auditReceipt(session, existing.kind === 'queue' ? 'QUEUE' : 'SEND', existing.run.id, existing.kind === 'queue' ? existing.item.id : undefined)
|
|
82
102
|
};
|
|
83
|
-
const goalMessage = internalGoal ? undefined : this.options.goalMessage(session, content);
|
|
103
|
+
const goalMessage = internalGoal || internalSchedule ? undefined : this.options.goalMessage(session, content);
|
|
84
104
|
const runnerContent = goalMessage?.runnerContent ?? internalRunnerContent;
|
|
85
105
|
const publicContent = goalMessage?.activated === true ? publicGoalStartText(content) : content;
|
|
86
106
|
const intent = goalMessage === undefined ? requestedIntent : 'REPLACE_CURRENT';
|
|
@@ -90,12 +110,15 @@ export class SessionMessageService {
|
|
|
90
110
|
const runId = randomUUID();
|
|
91
111
|
const queueBeforeCreate = this.options.runtime.sessionQueue(session.id);
|
|
92
112
|
const active = this.options.runtime.activeSessionRun(session.id);
|
|
113
|
+
if (internalSchedule && (active !== undefined || queueBeforeCreate.itemIds.length > 0))
|
|
114
|
+
throw new Error('SCHEDULE_SESSION_BUSY');
|
|
93
115
|
const replaceCurrentRun = intent === 'REPLACE_CURRENT' ? active : undefined;
|
|
94
116
|
const created = this.options.coordinator.accept({
|
|
95
117
|
runId,
|
|
96
118
|
sessionId: session.id,
|
|
97
119
|
clientMessageId,
|
|
98
120
|
content: publicContent,
|
|
121
|
+
...(internalSchedule ? { source: 'SCHEDULED' } : {}),
|
|
99
122
|
...(Number.isSafeInteger(input.__requestUserId)
|
|
100
123
|
? { initiatedByUserId: input.__requestUserId }
|
|
101
124
|
: {}),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ScheduledSessionStore } from './scheduled-session-store.js';
|
|
1
2
|
export class SessionPresentationService {
|
|
2
3
|
options;
|
|
3
4
|
constructor(options) {
|
|
@@ -18,7 +19,21 @@ export class SessionPresentationService {
|
|
|
18
19
|
});
|
|
19
20
|
return {
|
|
20
21
|
...session,
|
|
21
|
-
...presentation
|
|
22
|
+
...presentation,
|
|
23
|
+
runtime: {
|
|
24
|
+
...presentation.runtime,
|
|
25
|
+
capabilities: session.externalSessionId !== null && database !== undefined
|
|
26
|
+
? [...presentation.runtime.capabilities, 'session.schedule']
|
|
27
|
+
: presentation.runtime.capabilities
|
|
28
|
+
},
|
|
29
|
+
schedule: database === undefined
|
|
30
|
+
? null
|
|
31
|
+
: (() => {
|
|
32
|
+
const task = new ScheduledSessionStore(database.raw).get(session.externalSessionId ?? session.id);
|
|
33
|
+
return task === undefined
|
|
34
|
+
? null
|
|
35
|
+
: { enabled: task.enabled, nextRunAt: task.nextRunAt };
|
|
36
|
+
})()
|
|
22
37
|
};
|
|
23
38
|
}
|
|
24
39
|
presentDiscovered(sessions) {
|
|
@@ -28,7 +43,9 @@ export class SessionPresentationService {
|
|
|
28
43
|
return this.present(session);
|
|
29
44
|
const synchronized = session.runnerTitle === null
|
|
30
45
|
? projected
|
|
31
|
-
:
|
|
46
|
+
: session.titleSource === 'AUTO'
|
|
47
|
+
? this.options.runtime.setTemporaryTitleIfMissing(projected.id, session.runnerTitle)
|
|
48
|
+
: this.options.runtime.setRunnerTitleFromNative(projected.id, session.runnerTitle);
|
|
32
49
|
return this.present({
|
|
33
50
|
...synchronized,
|
|
34
51
|
lastActivityAt: latestSessionActivityAt(synchronized.lastActivityAt, session.lastActivityAt)
|