incremnt 0.8.9 → 0.8.10
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/package.json +1 -1
- package/src/contract.js +55 -4
- package/src/external-app-change-routes.js +274 -0
- package/src/format.js +4 -3
- package/src/remote.js +105 -0
- package/src/sync-service.js +139 -23
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const contractVersion =
|
|
1
|
+
export const contractVersion = 26;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
4
|
readOnly: false,
|
|
@@ -56,6 +56,17 @@ export const commandSchema = [
|
|
|
56
56
|
{ name: 'session-id', type: 'string', format: 'id', required: true, description: 'Session ID' }
|
|
57
57
|
]
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
command: 'external changes',
|
|
61
|
+
id: 'external-changes',
|
|
62
|
+
description: 'List pending/applied app changes created outside iOS',
|
|
63
|
+
supportsFields: true,
|
|
64
|
+
agentNotes: 'Use this after creating programs or importing sessions from CLI/MCP. Pending changes are queued for iOS to apply; applied/skipped/failed statuses come from the app acknowledgement.',
|
|
65
|
+
options: [
|
|
66
|
+
{ name: 'status', type: 'string', format: 'enum', enum: ['pending', 'applied', 'skipped', 'failed', 'dismissed'], required: false, description: 'Filter by status' },
|
|
67
|
+
{ name: 'limit', type: 'number', required: false, description: 'Max changes to return (default 50, max 100)' }
|
|
68
|
+
]
|
|
69
|
+
},
|
|
59
70
|
{
|
|
60
71
|
command: 'programs list',
|
|
61
72
|
id: 'program-list',
|
|
@@ -278,14 +289,35 @@ export const writeCommandSchema = [
|
|
|
278
289
|
{
|
|
279
290
|
command: 'programs create',
|
|
280
291
|
id: 'programs-create',
|
|
281
|
-
description: '
|
|
292
|
+
description: 'Queue an inactive iOS program for app-side import from a generated plan file',
|
|
282
293
|
usage: 'programs create --file <file>',
|
|
283
294
|
dryRun: true,
|
|
284
|
-
agentNotes: 'Mutation.
|
|
295
|
+
agentNotes: 'Mutation. Queues a brand-new inactive iOS program for app-side import from Coach-compatible generated-plan JSON; it does not activate or replace the current plan. External assistants should draft the JSON file, then run programs create --file <path>. Use external changes to watch pending/applied/failed status. Use records or exercises history before drafting exercise names; do not invent unusual exercise names. Supersets are supported with day-local supersetKey plus supersetOrder; the service converts them to native iOS supersetGroupId fields.',
|
|
285
296
|
options: [
|
|
286
297
|
{ name: 'file', type: 'string', format: 'path', required: true, description: 'Path to Coach-compatible generated-plan JSON file' }
|
|
287
298
|
]
|
|
288
299
|
},
|
|
300
|
+
{
|
|
301
|
+
command: 'sessions import',
|
|
302
|
+
id: 'sessions-import',
|
|
303
|
+
description: 'Queue generated workout history for iOS dedupe-and-append import',
|
|
304
|
+
usage: 'sessions import --file <file>',
|
|
305
|
+
dryRun: true,
|
|
306
|
+
agentNotes: 'Mutation. File must be JSON shaped as { sessions: [...] } using the iOS export WorkoutSession shape. The app dedupes by id and session fingerprint before appending, then acknowledges the external change.',
|
|
307
|
+
options: [
|
|
308
|
+
{ name: 'file', type: 'string', format: 'path', required: true, description: 'Path to JSON file with { sessions: [...] }' }
|
|
309
|
+
]
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
command: 'external changes backfill',
|
|
313
|
+
id: 'external-changes-backfill',
|
|
314
|
+
description: 'Backfill pending external changes from older cli-agent programs stranded in hosted snapshot state',
|
|
315
|
+
usage: 'external changes backfill',
|
|
316
|
+
dryRun: true,
|
|
317
|
+
mcp: false,
|
|
318
|
+
agentNotes: 'Mutation. Operational bridge for the previous CLI-created-program implementation. Recreates pending program.create changes for snapshot programs with externalInputProvenance.source=cli-agent.',
|
|
319
|
+
options: []
|
|
320
|
+
},
|
|
289
321
|
{
|
|
290
322
|
command: 'programs propose',
|
|
291
323
|
id: 'programs-propose',
|
|
@@ -501,7 +533,26 @@ export const proposalSchema = programDraftSchema;
|
|
|
501
533
|
|
|
502
534
|
export const writePayloadSchemas = {
|
|
503
535
|
'programs-create': programDraftSchema,
|
|
504
|
-
'programs-propose': programDraftSchema
|
|
536
|
+
'programs-propose': programDraftSchema,
|
|
537
|
+
'sessions-import': {
|
|
538
|
+
description: 'JSON structure for sessions import --file. Uses the iOS WorkoutSession export shape.',
|
|
539
|
+
required: ['sessions'],
|
|
540
|
+
properties: {
|
|
541
|
+
sessions: {
|
|
542
|
+
type: 'array',
|
|
543
|
+
items: {
|
|
544
|
+
required: ['id', 'date', 'volume', 'exercises'],
|
|
545
|
+
properties: {
|
|
546
|
+
id: { type: 'string', description: 'Stable session UUID' },
|
|
547
|
+
date: { type: 'string', description: 'Session date label or yyyy-MM-dd date' },
|
|
548
|
+
completedAt: { type: 'string', description: 'Optional ISO completion timestamp' },
|
|
549
|
+
volume: { type: 'integer', minimum: 0 },
|
|
550
|
+
exercises: { type: 'array', description: 'Logged exercises in iOS export shape' }
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
505
556
|
};
|
|
506
557
|
|
|
507
558
|
// Describes how token scope gates commands, so an agent can decide before
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
export async function handleExternalAppChangeRoute({
|
|
2
|
+
route,
|
|
3
|
+
request,
|
|
4
|
+
response,
|
|
5
|
+
requestToken,
|
|
6
|
+
token,
|
|
7
|
+
readAuthenticator,
|
|
8
|
+
connectedWriteAuthenticator,
|
|
9
|
+
mobileSyncAuthenticator,
|
|
10
|
+
createExternalChangeForAccount,
|
|
11
|
+
listExternalChangesForAccount,
|
|
12
|
+
updateExternalChangeResultForAccount,
|
|
13
|
+
backfillExternalChangesForAccount,
|
|
14
|
+
readJsonBody,
|
|
15
|
+
rejectInsufficientScopeForReadToken,
|
|
16
|
+
json,
|
|
17
|
+
methodNotAllowed,
|
|
18
|
+
unauthorized,
|
|
19
|
+
badRequest,
|
|
20
|
+
notFound
|
|
21
|
+
}) {
|
|
22
|
+
if (route.command === 'external-changes') {
|
|
23
|
+
if (request.method === 'GET' && request.url?.startsWith('/mobile/')) {
|
|
24
|
+
if (!listExternalChangesForAccount) {
|
|
25
|
+
methodNotAllowed(response, 'External change pull is not enabled for this service mode.');
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const account = mobileSyncAuthenticator
|
|
29
|
+
? await mobileSyncAuthenticator(requestToken)
|
|
30
|
+
: null;
|
|
31
|
+
if (!account) {
|
|
32
|
+
unauthorized(response, request);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const status = route.options.status ?? 'pending';
|
|
36
|
+
try {
|
|
37
|
+
json(response, 200, await listExternalChangesForAccount(account, { status, limit: 100 }));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
badRequest(response, error.message);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (request.method === 'GET') {
|
|
45
|
+
if (!listExternalChangesForAccount) {
|
|
46
|
+
methodNotAllowed(response, 'External change listing is not enabled for this service mode.');
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const account = readAuthenticator
|
|
50
|
+
? await readAuthenticator(requestToken)
|
|
51
|
+
: null;
|
|
52
|
+
if (!account) {
|
|
53
|
+
unauthorized(response, request);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
|
|
57
|
+
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
|
|
58
|
+
try {
|
|
59
|
+
json(response, 200, await listExternalChangesForAccount(account, {
|
|
60
|
+
status: route.options.status,
|
|
61
|
+
limit
|
|
62
|
+
}));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
badRequest(response, error.message);
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (request.method === 'POST') {
|
|
70
|
+
if (!createExternalChangeForAccount) {
|
|
71
|
+
methodNotAllowed(response, 'External change creation is not enabled for this service mode.');
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
const account = connectedWriteAuthenticator
|
|
75
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
76
|
+
: requestToken === token
|
|
77
|
+
? { id: 'remote-user', email: null }
|
|
78
|
+
: null;
|
|
79
|
+
if (!account) {
|
|
80
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
unauthorized(response, request);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const body = await readJsonBody(request);
|
|
88
|
+
const result = await createExternalChangeForAccount(account, body ?? {});
|
|
89
|
+
json(response, 201, { ok: true, externalChange: result });
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
badRequest(response, error.message);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
methodNotAllowed(response, 'Use GET or POST for /cli/external-changes, or GET for /mobile/external-changes.');
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (route.command === 'external-change-result') {
|
|
102
|
+
if (request.method !== 'POST') {
|
|
103
|
+
methodNotAllowed(response, 'Use POST for /mobile/external-changes/:id/result.');
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (!updateExternalChangeResultForAccount) {
|
|
107
|
+
methodNotAllowed(response, 'External change result updates are not enabled for this service mode.');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
const account = mobileSyncAuthenticator
|
|
111
|
+
? await mobileSyncAuthenticator(requestToken)
|
|
112
|
+
: null;
|
|
113
|
+
if (!account) {
|
|
114
|
+
unauthorized(response, request);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const body = await readJsonBody(request);
|
|
119
|
+
const result = await updateExternalChangeResultForAccount(account, route.options.id, body ?? {});
|
|
120
|
+
if (!result) {
|
|
121
|
+
notFound(response, 'External change not found.');
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
json(response, 200, { ok: true, externalChange: result });
|
|
125
|
+
return true;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
badRequest(response, error.message);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (route.command === 'external-changes-backfill') {
|
|
133
|
+
if (request.method !== 'POST') {
|
|
134
|
+
methodNotAllowed(response, 'Use POST for /cli/external-changes/backfill.');
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (!backfillExternalChangesForAccount) {
|
|
138
|
+
methodNotAllowed(response, 'External change backfill is not enabled for this service mode.');
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const account = connectedWriteAuthenticator
|
|
142
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
143
|
+
: requestToken === token
|
|
144
|
+
? { id: 'remote-user', email: null }
|
|
145
|
+
: null;
|
|
146
|
+
if (!account) {
|
|
147
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
unauthorized(response, request);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const result = await backfillExternalChangesForAccount(account);
|
|
155
|
+
json(response, 200, result);
|
|
156
|
+
return true;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
badRequest(response, error.message);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function handleAppNotificationRoute({
|
|
167
|
+
route,
|
|
168
|
+
request,
|
|
169
|
+
response,
|
|
170
|
+
requestToken,
|
|
171
|
+
token,
|
|
172
|
+
readAuthenticator,
|
|
173
|
+
notifications,
|
|
174
|
+
json,
|
|
175
|
+
logRequest,
|
|
176
|
+
methodNotAllowed,
|
|
177
|
+
unauthorized,
|
|
178
|
+
notFound,
|
|
179
|
+
badRequest,
|
|
180
|
+
internalError,
|
|
181
|
+
onError
|
|
182
|
+
}) {
|
|
183
|
+
if (!notifications) return false;
|
|
184
|
+
|
|
185
|
+
if (route.command === 'notifications') {
|
|
186
|
+
if (request.method !== 'GET') {
|
|
187
|
+
methodNotAllowed(response, 'Use GET for /cli/notifications.');
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
const notificationAccount = readAuthenticator
|
|
191
|
+
? await readAuthenticator(requestToken)
|
|
192
|
+
: requestToken === token
|
|
193
|
+
? { id: 'remote-user', email: null }
|
|
194
|
+
: null;
|
|
195
|
+
if (!notificationAccount) {
|
|
196
|
+
unauthorized(response, request);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 50;
|
|
200
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 50;
|
|
201
|
+
try {
|
|
202
|
+
const payload = await notifications.list(notificationAccount.id, {
|
|
203
|
+
limit,
|
|
204
|
+
before: route.options.before ?? null
|
|
205
|
+
});
|
|
206
|
+
logRequest(request, 200);
|
|
207
|
+
json(response, 200, payload);
|
|
208
|
+
return true;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
internalError(response, error, onError);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (route.command === 'notification-read') {
|
|
216
|
+
if (request.method !== 'POST') {
|
|
217
|
+
methodNotAllowed(response, 'Use POST for /cli/notifications/:id/read.');
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const notificationAccount = readAuthenticator
|
|
221
|
+
? await readAuthenticator(requestToken)
|
|
222
|
+
: requestToken === token
|
|
223
|
+
? { id: 'remote-user', email: null }
|
|
224
|
+
: null;
|
|
225
|
+
if (!notificationAccount) {
|
|
226
|
+
unauthorized(response, request);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const notification = await notifications.markRead(notificationAccount.id, route.options.notificationId);
|
|
231
|
+
if (!notification) {
|
|
232
|
+
notFound(response, 'Notification not found.');
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
logRequest(request, 200);
|
|
236
|
+
json(response, 200, notification);
|
|
237
|
+
return true;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (error?.message === 'notificationId is required.') {
|
|
240
|
+
badRequest(response, error.message);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
internalError(response, error, onError);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (route.command === 'notification-read-all') {
|
|
249
|
+
if (request.method !== 'POST') {
|
|
250
|
+
methodNotAllowed(response, 'Use POST for /cli/notifications/read-all.');
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
const notificationAccount = readAuthenticator
|
|
254
|
+
? await readAuthenticator(requestToken)
|
|
255
|
+
: requestToken === token
|
|
256
|
+
? { id: 'remote-user', email: null }
|
|
257
|
+
: null;
|
|
258
|
+
if (!notificationAccount) {
|
|
259
|
+
unauthorized(response, request);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const result = await notifications.markAllRead(notificationAccount.id);
|
|
264
|
+
logRequest(request, 200);
|
|
265
|
+
json(response, 200, result);
|
|
266
|
+
return true;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
internalError(response, error, onError);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return false;
|
|
274
|
+
}
|
package/src/format.js
CHANGED
|
@@ -599,15 +599,16 @@ function formatProposalCreated(payload) {
|
|
|
599
599
|
function formatProgramCreated(payload) {
|
|
600
600
|
if (!payload) return 'No program data.';
|
|
601
601
|
const lines = [
|
|
602
|
-
header('PROGRAM
|
|
602
|
+
header('PROGRAM QUEUED'),
|
|
603
603
|
'',
|
|
604
604
|
keyValue('ID', payload.id),
|
|
605
|
-
keyValue('
|
|
605
|
+
keyValue('External change', payload.externalChangeId ?? payload.externalChange?.id ?? '?'),
|
|
606
|
+
keyValue('Status', payload.status ?? 'pending'),
|
|
606
607
|
keyValue('Program', payload.name ?? 'Untitled'),
|
|
607
608
|
keyValue('Days/week', String(payload.daysPerWeek ?? '?')),
|
|
608
609
|
keyValue('Days', String(payload.days ?? payload.dayCount ?? '?')),
|
|
609
610
|
'',
|
|
610
|
-
chalk.dim('
|
|
611
|
+
chalk.dim(' Queued for iOS import. Run `incremnt external changes` to check applied/skipped/failed status.')
|
|
611
612
|
];
|
|
612
613
|
return lines.join('\n');
|
|
613
614
|
}
|
package/src/remote.js
CHANGED
|
@@ -133,6 +133,7 @@ async function throwForbiddenResponse(response, sessionState, commandId) {
|
|
|
133
133
|
const remoteCommandHandlers = {
|
|
134
134
|
'session-insights': executeRemoteRead,
|
|
135
135
|
'session-show': executeRemoteRead,
|
|
136
|
+
'external-changes': executeRemoteRead,
|
|
136
137
|
'exercise-history': executeRemoteRead,
|
|
137
138
|
records: executeRemoteRead,
|
|
138
139
|
'program-list': executeRemoteRead,
|
|
@@ -245,6 +246,12 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
245
246
|
return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/compare`);
|
|
246
247
|
case 'why-did-this-change':
|
|
247
248
|
return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/explain`);
|
|
249
|
+
case 'external-changes': {
|
|
250
|
+
const url = resolveServiceUrl(baseUrl, '/cli/external-changes');
|
|
251
|
+
if (options.status) url.searchParams.set('status', options.status);
|
|
252
|
+
if (options.limit) url.searchParams.set('limit', options.limit);
|
|
253
|
+
return url;
|
|
254
|
+
}
|
|
248
255
|
case 'program-list':
|
|
249
256
|
return resolveServiceUrl(baseUrl, '/cli/programs');
|
|
250
257
|
case 'program-summary':
|
|
@@ -501,6 +508,35 @@ export async function buildWriteRequest(commandId, options, sessionState) {
|
|
|
501
508
|
body: draft
|
|
502
509
|
};
|
|
503
510
|
}
|
|
511
|
+
case 'sessions-import': {
|
|
512
|
+
if (!options.file) {
|
|
513
|
+
const error = new Error('--file is required for sessions import.');
|
|
514
|
+
error.code = 'MISSING_OPTION';
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
const raw = await fs.readFile(options.file, 'utf8');
|
|
518
|
+
const payload = JSON.parse(raw);
|
|
519
|
+
return {
|
|
520
|
+
method: 'POST',
|
|
521
|
+
url: resolveServiceUrl(baseUrl, '/cli/external-changes').toString(),
|
|
522
|
+
body: {
|
|
523
|
+
kind: 'sessions.import',
|
|
524
|
+
source: {
|
|
525
|
+
surface: 'cli',
|
|
526
|
+
command: 'sessions import',
|
|
527
|
+
version: 1
|
|
528
|
+
},
|
|
529
|
+
payload
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
case 'external-changes-backfill': {
|
|
534
|
+
return {
|
|
535
|
+
method: 'POST',
|
|
536
|
+
url: resolveServiceUrl(baseUrl, '/cli/external-changes/backfill').toString(),
|
|
537
|
+
body: null
|
|
538
|
+
};
|
|
539
|
+
}
|
|
504
540
|
case 'programs-propose': {
|
|
505
541
|
if (!options.file) {
|
|
506
542
|
const error = new Error('--file is required for programs propose.');
|
|
@@ -719,6 +755,51 @@ const remoteWriteCommandHandlers = {
|
|
|
719
755
|
return response.json();
|
|
720
756
|
},
|
|
721
757
|
|
|
758
|
+
'sessions-import': async (options, sessionState) => {
|
|
759
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
760
|
+
if (!baseUrl) throw notImplementedError();
|
|
761
|
+
|
|
762
|
+
const filePath = options.file;
|
|
763
|
+
if (!filePath) {
|
|
764
|
+
const error = new Error('--file is required for sessions import.');
|
|
765
|
+
error.code = 'MISSING_OPTION';
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
770
|
+
const payload = JSON.parse(raw);
|
|
771
|
+
const body = {
|
|
772
|
+
kind: 'sessions.import',
|
|
773
|
+
source: {
|
|
774
|
+
surface: 'cli',
|
|
775
|
+
command: 'sessions import',
|
|
776
|
+
version: 1
|
|
777
|
+
},
|
|
778
|
+
payload
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const endpoint = resolveServiceUrl(baseUrl, '/cli/external-changes');
|
|
782
|
+
const response = await fetch(endpoint, {
|
|
783
|
+
method: 'POST',
|
|
784
|
+
headers: {
|
|
785
|
+
'Content-Type': 'application/json',
|
|
786
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
787
|
+
},
|
|
788
|
+
body: JSON.stringify(body)
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
if (response.status === 401) throw authenticationFailedError();
|
|
792
|
+
if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'sessions-import');
|
|
793
|
+
if (!response.ok) {
|
|
794
|
+
const responsePayload = await response.json().catch(() => null);
|
|
795
|
+
const error = new Error(responsePayload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
796
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
797
|
+
throw error;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return response.json();
|
|
801
|
+
},
|
|
802
|
+
|
|
722
803
|
'programs-proposals': async (options, sessionState) => {
|
|
723
804
|
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
724
805
|
if (!baseUrl) throw notImplementedError();
|
|
@@ -783,6 +864,30 @@ const remoteWriteCommandHandlers = {
|
|
|
783
864
|
return response.json();
|
|
784
865
|
},
|
|
785
866
|
|
|
867
|
+
'external-changes-backfill': async (_options, sessionState) => {
|
|
868
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
869
|
+
if (!baseUrl) throw notImplementedError();
|
|
870
|
+
|
|
871
|
+
const endpoint = resolveServiceUrl(baseUrl, '/cli/external-changes/backfill');
|
|
872
|
+
const response = await fetch(endpoint, {
|
|
873
|
+
method: 'POST',
|
|
874
|
+
headers: {
|
|
875
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
876
|
+
}
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
if (response.status === 401) throw authenticationFailedError();
|
|
880
|
+
if (response.status === 403) await throwForbiddenResponse(response, sessionState, 'external-changes-backfill');
|
|
881
|
+
if (!response.ok) {
|
|
882
|
+
const payload = await response.json().catch(() => null);
|
|
883
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
884
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
885
|
+
throw error;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return response.json();
|
|
889
|
+
},
|
|
890
|
+
|
|
786
891
|
'program-share-create': async (options, sessionState) => {
|
|
787
892
|
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
788
893
|
if (!baseUrl) throw notImplementedError();
|
package/src/sync-service.js
CHANGED
|
@@ -24,7 +24,11 @@ import { capabilities as cliCapabilities, contractVersion, officialCommands } fr
|
|
|
24
24
|
import { canonicalExerciseName, executeReadCommand } from './queries.js';
|
|
25
25
|
import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
|
|
26
26
|
import { compactScoreHistorySnapshots, enrichScoreSnapshots } from './score-context.js';
|
|
27
|
-
import { buildProgramFromDraft, extractAskProgramDraft } from './program-draft.js';
|
|
27
|
+
import { buildProgramFromDraft, extractAskProgramDraft, normalizeProgramDraft } from './program-draft.js';
|
|
28
|
+
import {
|
|
29
|
+
handleAppNotificationRoute,
|
|
30
|
+
handleExternalAppChangeRoute
|
|
31
|
+
} from './external-app-change-routes.js';
|
|
28
32
|
import { extractPlanChangeset } from './plan-changeset.js';
|
|
29
33
|
import { extractProgramScheduleAction } from './program-schedule-action.js';
|
|
30
34
|
import { extractCoachAdvice } from './coach-advice.js';
|
|
@@ -89,6 +93,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
89
93
|
'sync-account-preferences': 30,
|
|
90
94
|
'proposals': 30,
|
|
91
95
|
'proposal-update': 30,
|
|
96
|
+
'external-changes': 60,
|
|
97
|
+
'external-change-result': 60,
|
|
98
|
+
'external-changes-backfill': 10,
|
|
92
99
|
'program-share-create': 30,
|
|
93
100
|
'program-share-list': 60,
|
|
94
101
|
'program-share-public': 120,
|
|
@@ -133,6 +140,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
133
140
|
'social-media-complete': 30,
|
|
134
141
|
'social-media-delete': 30,
|
|
135
142
|
'social-post-update': 30,
|
|
143
|
+
'notifications': 60,
|
|
144
|
+
'notification-read': 60,
|
|
145
|
+
'notification-read-all': 30,
|
|
136
146
|
'social-notifications': 60,
|
|
137
147
|
'social-notification-read': 60,
|
|
138
148
|
'social-notification-read-all': 30,
|
|
@@ -311,6 +321,27 @@ function stableAdviceObservationKey(advice) {
|
|
|
311
321
|
return `ask-advice:${createHash('sha256').update(material).digest('hex').slice(0, 24)}`;
|
|
312
322
|
}
|
|
313
323
|
|
|
324
|
+
function stableJson(value) {
|
|
325
|
+
if (Array.isArray(value)) {
|
|
326
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
327
|
+
}
|
|
328
|
+
if (value && typeof value === 'object') {
|
|
329
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
330
|
+
}
|
|
331
|
+
return JSON.stringify(value);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function programCreateIdempotencyKey(rawDraft) {
|
|
335
|
+
const draft = normalizeProgramDraft(rawDraft, { strict: true });
|
|
336
|
+
if (!draft) {
|
|
337
|
+
throw new Error('Invalid generated program draft.');
|
|
338
|
+
}
|
|
339
|
+
const digest = createHash('sha256')
|
|
340
|
+
.update(stableJson({ command: 'programs create', version: 1, draft }), 'utf8')
|
|
341
|
+
.digest('hex');
|
|
342
|
+
return `program.create:cli-draft:sha256:${digest}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
314
345
|
function confidenceScore(confidence) {
|
|
315
346
|
if (confidence === 'high') return 0.85;
|
|
316
347
|
if (confidence === 'medium') return 0.7;
|
|
@@ -1492,6 +1523,25 @@ function routeRequest(url, method) {
|
|
|
1492
1523
|
return { command: 'mobile-sync-push', options: {} };
|
|
1493
1524
|
}
|
|
1494
1525
|
|
|
1526
|
+
if (pathname === '/mobile/external-changes') {
|
|
1527
|
+
return {
|
|
1528
|
+
command: 'external-changes',
|
|
1529
|
+
options: {
|
|
1530
|
+
status: url.searchParams.get('status') ?? 'pending'
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
{
|
|
1536
|
+
const externalChangeResultMatch = pathname.match(/^\/mobile\/external-changes\/([^/]+)\/result$/);
|
|
1537
|
+
if (externalChangeResultMatch) {
|
|
1538
|
+
return {
|
|
1539
|
+
command: 'external-change-result',
|
|
1540
|
+
options: { id: decodeURIComponent(externalChangeResultMatch[1]) }
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1495
1545
|
if (pathname === '/mobile/score-snapshots') {
|
|
1496
1546
|
return {
|
|
1497
1547
|
command: 'score-snapshots',
|
|
@@ -1566,6 +1616,20 @@ function routeRequest(url, method) {
|
|
|
1566
1616
|
return { command: 'proposals', options: { status: url.searchParams.get('status') ?? undefined } };
|
|
1567
1617
|
}
|
|
1568
1618
|
|
|
1619
|
+
if (pathname === '/cli/external-changes') {
|
|
1620
|
+
return {
|
|
1621
|
+
command: 'external-changes',
|
|
1622
|
+
options: {
|
|
1623
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
1624
|
+
limit: url.searchParams.get('limit') ?? undefined
|
|
1625
|
+
}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (pathname === '/cli/external-changes/backfill') {
|
|
1630
|
+
return { command: 'external-changes-backfill', options: {} };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1569
1633
|
{
|
|
1570
1634
|
const programShareCreateMatch = pathname.match(/^\/cli\/programs\/([^/]+)\/share$/);
|
|
1571
1635
|
if (programShareCreateMatch) {
|
|
@@ -2153,9 +2217,9 @@ function routeRequest(url, method) {
|
|
|
2153
2217
|
}
|
|
2154
2218
|
}
|
|
2155
2219
|
|
|
2156
|
-
if (pathname === '/cli/social/notifications') {
|
|
2220
|
+
if (pathname === '/cli/notifications' || pathname === '/cli/social/notifications') {
|
|
2157
2221
|
return {
|
|
2158
|
-
command: 'social-notifications',
|
|
2222
|
+
command: pathname === '/cli/notifications' ? 'notifications' : 'social-notifications',
|
|
2159
2223
|
options: {
|
|
2160
2224
|
limit: url.searchParams.get('limit') ?? undefined,
|
|
2161
2225
|
before: url.searchParams.get('before') ?? undefined
|
|
@@ -2163,15 +2227,15 @@ function routeRequest(url, method) {
|
|
|
2163
2227
|
};
|
|
2164
2228
|
}
|
|
2165
2229
|
|
|
2166
|
-
if (pathname === '/cli/social/notifications/read-all') {
|
|
2167
|
-
return { command: 'social-notification-read-all', options: {} };
|
|
2230
|
+
if (pathname === '/cli/notifications/read-all' || pathname === '/cli/social/notifications/read-all') {
|
|
2231
|
+
return { command: pathname === '/cli/notifications/read-all' ? 'notification-read-all' : 'social-notification-read-all', options: {} };
|
|
2168
2232
|
}
|
|
2169
2233
|
|
|
2170
2234
|
{
|
|
2171
|
-
const notificationReadMatch = pathname.match(/^\/cli\/social\/notifications\/([^/]+)\/read$/);
|
|
2235
|
+
const notificationReadMatch = pathname.match(/^\/cli\/(?:social\/)?notifications\/([^/]+)\/read$/);
|
|
2172
2236
|
if (notificationReadMatch) {
|
|
2173
2237
|
return {
|
|
2174
|
-
command: 'social-notification-read',
|
|
2238
|
+
command: pathname.startsWith('/cli/notifications/') ? 'notification-read' : 'social-notification-read',
|
|
2175
2239
|
options: { notificationId: decodeURIComponent(notificationReadMatch[1]) }
|
|
2176
2240
|
};
|
|
2177
2241
|
}
|
|
@@ -2780,6 +2844,10 @@ export function createSyncServiceRequestHandler({
|
|
|
2780
2844
|
createProposalForAccount = null,
|
|
2781
2845
|
listProposalsForAccount = null,
|
|
2782
2846
|
updateProposalForAccount = null,
|
|
2847
|
+
createExternalChangeForAccount = null,
|
|
2848
|
+
listExternalChangesForAccount = null,
|
|
2849
|
+
updateExternalChangeResultForAccount = null,
|
|
2850
|
+
backfillExternalChangesForAccount = null,
|
|
2783
2851
|
createProgramShareForAccount = null,
|
|
2784
2852
|
listProgramSharesForAccount = null,
|
|
2785
2853
|
readPublicProgramShare = null,
|
|
@@ -2823,6 +2891,7 @@ export function createSyncServiceRequestHandler({
|
|
|
2823
2891
|
recordCoachObservationOutcomeForAccount = null,
|
|
2824
2892
|
recordCoachObservationFeedbackForAccount = null,
|
|
2825
2893
|
// Social
|
|
2894
|
+
notifications = null,
|
|
2826
2895
|
social = null,
|
|
2827
2896
|
onError = null
|
|
2828
2897
|
}) {
|
|
@@ -3810,6 +3879,30 @@ export function createSyncServiceRequestHandler({
|
|
|
3810
3879
|
return;
|
|
3811
3880
|
}
|
|
3812
3881
|
|
|
3882
|
+
if (await handleExternalAppChangeRoute({
|
|
3883
|
+
route,
|
|
3884
|
+
request,
|
|
3885
|
+
response,
|
|
3886
|
+
requestToken,
|
|
3887
|
+
token,
|
|
3888
|
+
readAuthenticator,
|
|
3889
|
+
connectedWriteAuthenticator,
|
|
3890
|
+
mobileSyncAuthenticator,
|
|
3891
|
+
createExternalChangeForAccount,
|
|
3892
|
+
listExternalChangesForAccount,
|
|
3893
|
+
updateExternalChangeResultForAccount,
|
|
3894
|
+
backfillExternalChangesForAccount,
|
|
3895
|
+
readJsonBody,
|
|
3896
|
+
rejectInsufficientScopeForReadToken,
|
|
3897
|
+
json,
|
|
3898
|
+
methodNotAllowed,
|
|
3899
|
+
unauthorized,
|
|
3900
|
+
badRequest,
|
|
3901
|
+
notFound
|
|
3902
|
+
})) {
|
|
3903
|
+
return;
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3813
3906
|
if (route.command === 'score-snapshots') {
|
|
3814
3907
|
if (request.method === 'POST') {
|
|
3815
3908
|
if (!insertScoreSnapshotsForAccount) {
|
|
@@ -7852,6 +7945,26 @@ export function createSyncServiceRequestHandler({
|
|
|
7852
7945
|
}
|
|
7853
7946
|
}
|
|
7854
7947
|
|
|
7948
|
+
if (await handleAppNotificationRoute({
|
|
7949
|
+
route,
|
|
7950
|
+
request,
|
|
7951
|
+
response,
|
|
7952
|
+
requestToken,
|
|
7953
|
+
token,
|
|
7954
|
+
readAuthenticator,
|
|
7955
|
+
notifications,
|
|
7956
|
+
json,
|
|
7957
|
+
logRequest,
|
|
7958
|
+
methodNotAllowed,
|
|
7959
|
+
unauthorized,
|
|
7960
|
+
notFound,
|
|
7961
|
+
badRequest,
|
|
7962
|
+
internalError,
|
|
7963
|
+
onError
|
|
7964
|
+
})) {
|
|
7965
|
+
return;
|
|
7966
|
+
}
|
|
7967
|
+
|
|
7855
7968
|
if (social && route.command === 'social-notifications') {
|
|
7856
7969
|
if (request.method !== 'GET') {
|
|
7857
7970
|
methodNotAllowed(response, 'Use GET for /cli/social/notifications.');
|
|
@@ -8014,7 +8127,7 @@ export function createSyncServiceRequestHandler({
|
|
|
8014
8127
|
}
|
|
8015
8128
|
|
|
8016
8129
|
if (route.command === 'program-list' && request.method === 'POST') {
|
|
8017
|
-
if (!
|
|
8130
|
+
if (!createExternalChangeForAccount) {
|
|
8018
8131
|
methodNotAllowed(response, 'Program creation is not enabled for this service mode.');
|
|
8019
8132
|
return;
|
|
8020
8133
|
}
|
|
@@ -8034,25 +8147,28 @@ export function createSyncServiceRequestHandler({
|
|
|
8034
8147
|
|
|
8035
8148
|
try {
|
|
8036
8149
|
const body = await readJsonBody(request);
|
|
8150
|
+
const idempotencyKey = programCreateIdempotencyKey(body);
|
|
8037
8151
|
const program = buildProgramFromDraft(body);
|
|
8038
|
-
await
|
|
8039
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
8042
|
-
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
]
|
|
8152
|
+
const externalChange = await createExternalChangeForAccount(writeAccount, {
|
|
8153
|
+
kind: 'program.create',
|
|
8154
|
+
source: {
|
|
8155
|
+
surface: 'cli',
|
|
8156
|
+
command: 'programs create',
|
|
8157
|
+
version: 1
|
|
8158
|
+
},
|
|
8159
|
+
payload: { program },
|
|
8160
|
+
idempotencyKey
|
|
8048
8161
|
});
|
|
8162
|
+
const queuedProgram = externalChange?.payload?.program ?? program;
|
|
8049
8163
|
json(response, 201, {
|
|
8050
8164
|
ok: true,
|
|
8051
|
-
id:
|
|
8052
|
-
name:
|
|
8053
|
-
status:
|
|
8054
|
-
|
|
8055
|
-
|
|
8165
|
+
id: queuedProgram.id,
|
|
8166
|
+
name: queuedProgram.name,
|
|
8167
|
+
status: externalChange.status,
|
|
8168
|
+
externalChangeId: externalChange.id,
|
|
8169
|
+
externalChange,
|
|
8170
|
+
daysPerWeek: queuedProgram.daysPerWeek,
|
|
8171
|
+
days: queuedProgram.days.length
|
|
8056
8172
|
});
|
|
8057
8173
|
return;
|
|
8058
8174
|
} catch (error) {
|