spectater 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/review.js CHANGED
@@ -103,6 +103,7 @@ export async function openReview(options, dependencies = {}) {
103
103
  .filter((c) => c.deletedAt)
104
104
  .map((c) => ({ ...c, ...locate(c, current.get(c.documentId)) })),
105
105
  endedAt: state.endedAt,
106
+ endRequestedAt: state.endRequestedAt,
106
107
  changes: sourceChanges(state),
107
108
  hasPreviousSource: Boolean(state.previousDocuments),
108
109
  activity: state.activity || {},
@@ -110,7 +111,7 @@ export async function openReview(options, dependencies = {}) {
110
111
  pendingCommentIds: pendingComments(state).map((comment) => comment.id),
111
112
  batches: state.batches.map(({ entries, ...batch }) => ({
112
113
  ...batch,
113
- count: entries.length,
114
+ count: entries.length + (batch.message ? 1 : 0),
114
115
  commentIds: entries.map((entry) => entry.id),
115
116
  })),
116
117
  snapshots: Object.fromEntries(state.comments.map((c) => [c.hash, state.snapshots[c.hash]])),
@@ -128,6 +129,153 @@ export async function openReview(options, dependencies = {}) {
128
129
  }
129
130
  }
130
131
  }
132
+ const now = () => new Date().toISOString();
133
+ const outstanding = (state) =>
134
+ state.batches.some((batch) => ['queued', 'editing'].includes(batchStatus(state, batch.id)));
135
+ const finishPendingEnd = (state) => {
136
+ if (state.endRequestedAt && !outstanding(state)) {
137
+ state.endedAt = now();
138
+ delete state.endRequestedAt;
139
+ }
140
+ };
141
+ const text = (value, label) => {
142
+ if (typeof value !== 'string' || !value.trim() || value.length > 10000)
143
+ throw new Error(`${label} must contain 1–10,000 characters.`);
144
+ return value.trim();
145
+ };
146
+ const metadataActions = new Set(['end', 'resume', 'cancel-batch', 'edit-batch']);
147
+ const findComment = (state, id) => {
148
+ const comment = state.comments.find((c) => c.id === id);
149
+ if (!comment) throw new Error('Unknown comment.');
150
+ return comment;
151
+ };
152
+ const liveComment = (state, id) => {
153
+ const comment = findComment(state, id);
154
+ if (comment.deletedAt) throw new Error('Comment is deleted. Restore it before editing.');
155
+ return comment;
156
+ };
157
+ const queuedBatch = (state, id) => {
158
+ const batch = state.batches.find((item) => item.id === id);
159
+ if (!batch) throw new Error('Unknown feedback batch.');
160
+ if (!['queued', 'editing'].includes(batchStatus(state, batch.id)))
161
+ throw new Error('Batch already handed off or withdrawn. Send a correction instead.');
162
+ return batch;
163
+ };
164
+ const actions = {
165
+ end(state) {
166
+ if (outstanding(state))
167
+ throw new Error('Deliver or cancel queued submissions before ending the review.');
168
+ state.endedAt ||= now();
169
+ delete state.endRequestedAt;
170
+ },
171
+ resume(state) {
172
+ delete state.endedAt;
173
+ delete state.endRequestedAt;
174
+ },
175
+ 'cancel-batch'(state, input) {
176
+ const batch = queuedBatch(state, input.id);
177
+ state.queue ||= {};
178
+ state.queue[batch.id] = { status: 'cancelled', at: now() };
179
+ finishPendingEnd(state);
180
+ },
181
+ 'edit-batch'(state, input) {
182
+ const batch = queuedBatch(state, input.id);
183
+ if (
184
+ state.batches.some(
185
+ (item) => item.id !== batch.id && batchStatus(state, item.id) === 'editing',
186
+ )
187
+ )
188
+ throw new Error('Finish or cancel the current queued edit first.');
189
+ state.queue ||= {};
190
+ state.queue[batch.id] = { status: 'editing', at: now() };
191
+ },
192
+ comment(state, input) {
193
+ const snapshot = current.get(input.documentId);
194
+ if (!snapshot || snapshot.hash !== input.hash)
195
+ throw new Error('Revision conflict: select the current document.');
196
+ state.comments.push({
197
+ id: randomUUID(),
198
+ documentId: input.documentId,
199
+ path: snapshot.path,
200
+ hash: snapshot.hash,
201
+ ...anchor(snapshot, input),
202
+ body: text(input.body, 'Comment'),
203
+ createdAt: now(),
204
+ state: 'open',
205
+ });
206
+ },
207
+ submit(state, input) {
208
+ const message = input.message ? text(input.message, 'Message') : undefined;
209
+ const entries = pendingComments(state).map((c) => ({
210
+ ...c,
211
+ line: state.snapshots[c.hash].source.slice(0, c.start).split('\n').length,
212
+ }));
213
+ const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
214
+ if (!entries.length && !message) {
215
+ if (input.end && !editing) {
216
+ if (outstanding(state)) state.endRequestedAt = now();
217
+ else actions.end(state);
218
+ return;
219
+ }
220
+ throw new Error(
221
+ editing
222
+ ? 'Keep an open comment or cancel the queued batch.'
223
+ : 'Add a message or an open comment that is new or changed before submitting.',
224
+ );
225
+ }
226
+ const batch = {
227
+ id: randomUUID(),
228
+ sequence: state.batches.length + 1,
229
+ submittedAt: now(),
230
+ ...(message ? { message } : {}),
231
+ entries,
232
+ };
233
+ state.batches.push(batch);
234
+ if (editing)
235
+ state.queue[editing.id] = {
236
+ status: 'superseded',
237
+ at: batch.submittedAt,
238
+ replacementId: batch.id,
239
+ };
240
+ if (input.end) state.endRequestedAt = batch.submittedAt;
241
+ },
242
+ restore(state, input) {
243
+ delete findComment(state, input.id).deletedAt;
244
+ },
245
+ delete(state, input) {
246
+ liveComment(state, input.id).deletedAt = now();
247
+ },
248
+ edit(state, input) {
249
+ const comment = liveComment(state, input.id);
250
+ comment.body = text(input.body, 'Comment');
251
+ comment.updatedAt = now();
252
+ },
253
+ resolve(state, input) {
254
+ const comment = liveComment(state, input.id);
255
+ comment.state = comment.state === 'open' ? 'resolved' : 'open';
256
+ },
257
+ reattach(state, input) {
258
+ const comment = liveComment(state, input.id);
259
+ const snapshot = current.get(comment.documentId);
260
+ const result = locate(comment, snapshot);
261
+ if (result.status !== 'stale')
262
+ throw new Error('No unique suggested anchor. Select source and add a new comment.');
263
+ comment.previousAnchors = [
264
+ ...(comment.previousAnchors || []),
265
+ { hash: comment.hash, start: comment.start, end: comment.end, slice: comment.slice },
266
+ ];
267
+ Object.assign(
268
+ comment,
269
+ anchor(snapshot, {
270
+ start: result.suggestion,
271
+ end: result.suggestion + comment.slice.length,
272
+ precision: 'exact',
273
+ quote: comment.slice,
274
+ }),
275
+ { hash: snapshot.hash },
276
+ );
277
+ },
278
+ };
131
279
  try {
132
280
  await refresh(review);
133
281
  } catch (error) {
@@ -149,38 +297,50 @@ export async function openReview(options, dependencies = {}) {
149
297
  return view();
150
298
  }),
151
299
  compare: (id) => compareSources(store.state, id, dependencies.diff),
152
- claimBatch: ({ batchId, after }) =>
300
+ // Delivers the oldest batch the agent has not replied to, so a lost response is redelivered.
301
+ deliver: () =>
153
302
  serial(async () => {
154
303
  const state = store.state;
155
- if (state.endedAt)
156
- throw new Error('Review ended. Use --history for saved feedback or resume explicitly.');
157
- const batch = batchId
158
- ? state.batches.find((item) => item.id === batchId)
159
- : state.batches.find((item) => item.sequence > after && deliverable(state, item));
160
- if (!batch) {
161
- if (batchId) throw new Error('Unknown feedback batch.');
162
- return null;
163
- }
164
- if (!deliverable(state, batch))
165
- throw new Error('Batch is withdrawn or being edited. Use --history for an archive copy.');
166
- if (batchStatus(state, batch.id) !== 'claimed') {
304
+ const batch = state.batches.find(
305
+ (item) => deliverable(state, item) && state.activity?.[item.id]?.status !== 'replied',
306
+ );
307
+ // An ended review still redelivers a claimed batch until the agent replies to it.
308
+ if (!batch || (state.endedAt && batchStatus(state, batch.id) !== 'claimed')) return null;
309
+ if (batchStatus(state, batch.id) === 'queued') {
310
+ const at = new Date().toISOString();
167
311
  state.queue ||= {};
168
- state.queue[batch.id] = { status: 'claimed', at: new Date().toISOString() };
312
+ state.queue[batch.id] = { status: 'claimed', at };
313
+ state.activity ||= {};
314
+ state.activity[batch.id] = { receivedAt: at, status: 'received', replies: [] };
315
+ finishPendingEnd(state);
169
316
  state.revision++;
170
317
  await store.commit(state);
171
318
  notify({ type: 'activity', revision: state.revision });
319
+ if (state.endedAt) notify({ type: 'lifecycle', revision: state.revision, ended: true });
172
320
  }
173
321
  return feedback(state, batch.id);
174
322
  }),
175
- recordActivity: (batchId, input) =>
323
+ recordActivity: (sequence, input) =>
176
324
  serial(async () => {
177
325
  const state = store.state;
178
- if (!state.batches.some((batch) => batch.id === batchId))
179
- throw new Error('Unknown feedback batch.');
180
- if (!['queued', 'claimed'].includes(batchStatus(state, batchId)))
181
- throw new Error('Batch is withdrawn or being edited.');
182
- if (!['received', 'working', 'done'].includes(input.status))
183
- throw new Error('Activity status must be received, working or done.');
326
+ const batch =
327
+ sequence === undefined
328
+ ? state.batches.find(
329
+ (item) =>
330
+ batchStatus(state, item.id) === 'claimed' &&
331
+ state.activity?.[item.id]?.status !== 'replied',
332
+ )
333
+ : state.batches.find((item) => item.sequence === sequence);
334
+ if (!batch)
335
+ throw new Error(
336
+ sequence === undefined
337
+ ? 'No delivered batch is awaiting a reply. Pass --batch to reply to an earlier one.'
338
+ : `Unknown feedback batch: ${sequence}.`,
339
+ );
340
+ if (batchStatus(state, batch.id) !== 'claimed')
341
+ throw new Error('Batch has not been delivered to the agent.');
342
+ if (!['working', 'done'].includes(input.status))
343
+ throw new Error('Reply status must be working or done.');
184
344
  const body = input.body ?? '';
185
345
  if (
186
346
  typeof body !== 'string' ||
@@ -188,13 +348,7 @@ export async function openReview(options, dependencies = {}) {
188
348
  (input.status === 'done' && !body.trim())
189
349
  )
190
350
  throw new Error('A reply must contain 1–10,000 characters.');
191
- state.activity ||= {};
192
- state.activity[batchId] ||= {
193
- receivedAt: new Date().toISOString(),
194
- status: 'received',
195
- replies: [],
196
- };
197
- const activity = state.activity[batchId];
351
+ const activity = state.activity[batch.id];
198
352
  const previous = JSON.stringify(activity);
199
353
  if (input.status === 'working' && activity.status !== 'replied') {
200
354
  activity.startedAt ||= new Date().toISOString();
@@ -208,130 +362,31 @@ export async function openReview(options, dependencies = {}) {
208
362
  });
209
363
  activity.status = 'replied';
210
364
  }
211
- if (store.state.activity?.[batchId] && previous === JSON.stringify(activity)) return view();
212
- state.revision++;
213
- await store.commit(state);
214
- notify({ type: 'activity', revision: state.revision });
215
- return view();
365
+ if (previous !== JSON.stringify(activity)) {
366
+ state.revision++;
367
+ await store.commit(state);
368
+ notify({ type: 'activity', revision: state.revision });
369
+ }
370
+ return { batch: batch.sequence, activity: activity.status };
216
371
  }),
217
372
  mutate: (input) =>
218
373
  serial(async () => {
374
+ const handler = actions[input.action];
375
+ if (!handler) throw new Error('Unknown review action.');
219
376
  if (store.state.endedAt && !['resume', 'end'].includes(input.action))
220
377
  throw new Error('Review ended. Resume explicitly to change feedback.');
221
- if (['end', 'resume', 'cancel-batch', 'edit-batch'].includes(input.action)) {
378
+ if (metadataActions.has(input.action)) {
222
379
  if (input.revision !== store.state.revision)
223
380
  throw new Error('Revision conflict. Refresh review state and retry.');
224
381
  } else await checkRevision(input);
225
382
  if (input.action === 'resume' && store.state.endedAt) await refresh();
226
383
  const state = store.state;
227
- if (['end', 'resume'].includes(input.action)) {
228
- if (input.action === 'end') {
229
- if (
230
- state.batches.some((batch) =>
231
- ['queued', 'editing'].includes(batchStatus(state, batch.id)),
232
- )
233
- )
234
- throw new Error('Deliver or cancel queued submissions before ending the review.');
235
- state.endedAt ||= new Date().toISOString();
236
- } else delete state.endedAt;
237
- } else if (['cancel-batch', 'edit-batch'].includes(input.action)) {
238
- const batch = state.batches.find((item) => item.id === input.id);
239
- if (!batch) throw new Error('Unknown feedback batch.');
240
- const status = batchStatus(state, batch.id);
241
- if (!['queued', 'editing'].includes(status))
242
- throw new Error('Batch already handed off or withdrawn. Send a correction instead.');
243
- if (
244
- input.action === 'edit-batch' &&
245
- state.batches.some(
246
- (item) => item.id !== batch.id && batchStatus(state, item.id) === 'editing',
247
- )
248
- )
249
- throw new Error('Finish or cancel the current queued edit first.');
250
- state.queue ||= {};
251
- state.queue[batch.id] = {
252
- status: input.action === 'edit-batch' ? 'editing' : 'cancelled',
253
- at: new Date().toISOString(),
254
- };
255
- } else if (input.action === 'comment') {
256
- const snapshot = current.get(input.documentId);
257
- if (!snapshot || snapshot.hash !== input.hash)
258
- throw new Error('Revision conflict: select the current document.');
259
- if (typeof input.body !== 'string' || !input.body.trim() || input.body.length > 10000)
260
- throw new Error('Comment must contain 1–10,000 characters.');
261
- state.comments.push({
262
- id: randomUUID(),
263
- documentId: input.documentId,
264
- path: snapshot.path,
265
- hash: snapshot.hash,
266
- ...anchor(snapshot, input),
267
- body: input.body.trim(),
268
- createdAt: new Date().toISOString(),
269
- state: 'open',
270
- });
271
- } else if (input.action === 'submit') {
272
- const entries = pendingComments(state).map((c) => ({
273
- ...c,
274
- line: state.snapshots[c.hash].source.slice(0, c.start).split('\n').length,
275
- }));
276
- const editing = state.batches.find((batch) => batchStatus(state, batch.id) === 'editing');
277
- if (!entries.length)
278
- throw new Error(
279
- editing
280
- ? 'Keep an open comment or cancel the queued batch.'
281
- : 'Add an open comment that is new or changed before submitting.',
282
- );
283
- const batch = {
284
- id: randomUUID(),
285
- sequence: state.batches.length + 1,
286
- submittedAt: new Date().toISOString(),
287
- entries,
288
- };
289
- state.batches.push(batch);
290
- if (editing)
291
- state.queue[editing.id] = {
292
- status: 'superseded',
293
- at: batch.submittedAt,
294
- replacementId: batch.id,
295
- };
296
- } else if (['resolve', 'reattach', 'edit', 'delete', 'restore'].includes(input.action)) {
297
- const comment = state.comments.find((c) => c.id === input.id);
298
- if (!comment) throw new Error('Unknown comment.');
299
- if (input.action === 'restore') delete comment.deletedAt;
300
- else if (comment.deletedAt)
301
- throw new Error('Comment is deleted. Restore it before editing.');
302
- else if (input.action === 'delete') comment.deletedAt = new Date().toISOString();
303
- else if (input.action === 'edit') {
304
- if (typeof input.body !== 'string' || !input.body.trim() || input.body.length > 10000)
305
- throw new Error('Comment must contain 1–10,000 characters.');
306
- comment.body = input.body.trim();
307
- comment.updatedAt = new Date().toISOString();
308
- } else if (input.action === 'resolve')
309
- comment.state = comment.state === 'open' ? 'resolved' : 'open';
310
- else {
311
- const snapshot = current.get(comment.documentId);
312
- const result = locate(comment, snapshot);
313
- if (result.status !== 'stale')
314
- throw new Error('No unique suggested anchor. Select source and add a new comment.');
315
- comment.previousAnchors = [
316
- ...(comment.previousAnchors || []),
317
- { hash: comment.hash, start: comment.start, end: comment.end, slice: comment.slice },
318
- ];
319
- Object.assign(
320
- comment,
321
- anchor(snapshot, {
322
- start: result.suggestion,
323
- end: result.suggestion + comment.slice.length,
324
- precision: 'exact',
325
- quote: comment.slice,
326
- }),
327
- { hash: snapshot.hash },
328
- );
329
- }
330
- } else throw new Error('Unknown review action.');
384
+ handler(state, input);
331
385
  state.revision++;
332
386
  await store.commit(state);
387
+ const lifecycle = ['end', 'resume'].includes(input.action) || Boolean(state.endedAt);
333
388
  notify({
334
- type: ['end', 'resume'].includes(input.action) ? 'lifecycle' : 'activity',
389
+ type: lifecycle ? 'lifecycle' : 'activity',
335
390
  revision: state.revision,
336
391
  ended: Boolean(state.endedAt),
337
392
  });