@ours.network/fleet 2.3.2 → 2.3.4

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/spawn.js CHANGED
@@ -2,6 +2,7 @@ import { taskSupervisorMayRun } from './task-supervision.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { storeRoomSecret, storeTemporaryLaunch, preparePermanentAssignment } from './agent-ours/service.js';
4
4
  import { ensureWorkspace } from './rooms-tasks/workspace.js';
5
+ import { assertCwdNotInRemovedTaskFolder } from './rooms-tasks/workspace-removal.js';
5
6
  import { spawn as spawnChild } from 'node:child_process';
6
7
  import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
8
  import { join } from 'node:path';
@@ -324,6 +325,7 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
324
325
  if (o.isolationFile)
325
326
  readIsolationFile(o.isolationFile); // fail before reserving
326
327
  const prepared = resolvedSpawn(o); // canonical validation before mutation
328
+ assertCwdNotInRemovedTaskFolder(prepared.role.cwd);
327
329
  if ((o.taskOwner || o.roomMemberStartup?.task_id) && !getAdapter(prepared.role.harness).supportsResume)
328
330
  throw Error('TASK_HARNESS_RESUME_UNSUPPORTED: task agents require a resumable harness');
329
331
  if (o.roomMemberStartup?.workspace) {
@@ -23,8 +23,10 @@ import { AuditSink } from './audit.js';
23
23
  import { WebAuth } from './auth.js';
24
24
  import { FleetEventBus } from './events.js';
25
25
  import { ROLE_NAME_RE } from '../config.js';
26
+ import { workingFilesSummary } from '../rooms-tasks/workspace-removal.js';
26
27
  import { TaskListError } from '../rooms-tasks/task-lists.js';
27
28
  import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, resolveTaskRef, TaskStateError } from '../rooms-tasks/task-state.js';
29
+ import { CONTRIBUTION_CUTOFF_MAX, CONTRIBUTION_PAGE_MAX, listTaskContributions, readTaskContributions } from '../rooms-tasks/task-contributions.js';
28
30
  import { MissionKeyError } from '../rooms-tasks/mission-keys.js';
29
31
  import { appendTaskProgress, getTaskReview, taskProgressSnapshot, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
30
32
  import { isProvider } from '../subscriptions/store.js';
@@ -389,7 +391,7 @@ export async function buildWebServer(services, boundary, options = {}) {
389
391
  /** Browser task payloads carry Progress/To review counts; bodies have their own routes. */
390
392
  const wireTask = (task) => {
391
393
  const { progress: _progress, review: _review, member_naming: _naming, ...rest } = task;
392
- return { ...rest, ...taskContributionSummary(task) };
394
+ return { ...rest, ...taskContributionSummary(task), working_files: workingFilesSummary(task) };
393
395
  };
394
396
  const wireTaskResult = (result) => {
395
397
  const value = result;
@@ -624,6 +626,14 @@ export async function buildWebServer(services, boundary, options = {}) {
624
626
  recovery: `Repeat DELETE /api/v1/tasks/${submitted}?confirm=${submitted}.`,
625
627
  };
626
628
  });
629
+ // Removes only the Done task's own working folder; the task, Progress, To review files and chat history stay.
630
+ app.post('/api/v1/tasks/:id/working-files/remove', async (request) => {
631
+ auth.authenticate(request, true);
632
+ const result = await taskApi(() => requireTaskRooms().removeTaskWorkingFiles({
633
+ actor: { kind: 'local_control', surface: 'web' }, taskId: taskRef(request),
634
+ }));
635
+ return { task: wireTask(result.task), result: result.result, mentions: result.mentions };
636
+ });
627
637
  app.post('/api/v1/tasks', async (request, reply) => {
628
638
  auth.authenticate(request, true);
629
639
  const body = request.body;
@@ -686,6 +696,39 @@ export async function buildWebServer(services, boundary, options = {}) {
686
696
  reply.code(201);
687
697
  return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
688
698
  });
699
+ /**
700
+ * Progress and To review of every task, newest first, for the notification list. The first page
701
+ * (no `before`) also carries the cutoff a bulk read may acknowledge up to: each task with unread
702
+ * items as it was read, one task at a time (per task, not one atomic instant across tasks).
703
+ */
704
+ app.get('/api/v1/contributions', async (request) => {
705
+ auth.authenticate(request);
706
+ const query = request.query;
707
+ if (query.unread !== undefined && !['true', 'false'].includes(query.unread))
708
+ throw new FleetError('invalid_request', 'unread must be true or false');
709
+ if (query.limit !== undefined && !/^\d{1,3}$/.test(query.limit))
710
+ throw new FleetError('invalid_request', `limit must be 1-${CONTRIBUTION_PAGE_MAX}`);
711
+ const page = await reviewApi(() => listTaskContributions({
712
+ ...(query.before !== undefined ? { before: query.before } : {}),
713
+ ...(query.limit !== undefined ? { limit: Number(query.limit) } : {}),
714
+ unread: query.unread === 'true',
715
+ }));
716
+ const { cutoff, ...rest } = page;
717
+ return query.before === undefined ? page : rest;
718
+ });
719
+ /** Bulk read up to a captured cutoff, at most `CONTRIBUTION_CUTOFF_MAX` tasks per request, with a result per task. */
720
+ app.post('/api/v1/contributions/read', async (request) => {
721
+ auth.authenticate(request, true);
722
+ const cutoff = request.body?.cutoff;
723
+ const valid = (item) => !!item && typeof item === 'object' && Object.keys(item).length === 4
724
+ && typeof item.task_id === 'string' && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(item.task_id) && Number.isSafeInteger(item.progress) && item.progress >= 0
725
+ && (item.progress_last === null || (typeof item.progress_last === 'string' && item.progress_last.length <= 64))
726
+ && Number.isSafeInteger(item.review) && item.review >= 0;
727
+ if (!Array.isArray(cutoff) || cutoff.length > CONTRIBUTION_CUTOFF_MAX || !cutoff.every(valid)
728
+ || new Set(cutoff.map(item => item.task_id)).size !== cutoff.length)
729
+ throw new FleetError('invalid_request', `cutoff must list at most ${CONTRIBUTION_CUTOFF_MAX} distinct tasks as captured`);
730
+ return { results: await reviewApi(() => readTaskContributions(cutoff)) };
731
+ });
689
732
  /** Acknowledge Progress entries the browser actually showed; returns the task's remaining unread. */
690
733
  app.post('/api/v1/tasks/:id/progress/read', async (request) => {
691
734
  auth.authenticate(request, true);
@@ -155,6 +155,8 @@ workspace-device bearer. `GET /api/v1/meta` reports API minor version 1.
155
155
  | `GET /tasks/:id/review` | `{review: {revision, markdown, updated_at, updated_by, artifacts: [{id, name, mime, size, sha256, added_at, added_by}]}}` or `{review: null}`. |
156
156
  | `PUT /tasks/:id/review` | `{markdown, expectedMarkdown}`; 409 when the Markdown changed meanwhile. |
157
157
  | `GET /tasks/:id/review/artifacts/:artifactId` | The retained bytes as `application/octet-stream` attachment with `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff` and `no-store`. Another task's artifact ID is 404. |
158
+ | `GET /contributions?limit=1-100&before=&unread=true` | Every task's Progress entries and current To review revision for the notification list, newest first: `{items: [{id, kind, task_id, mission_key?, task_title, task_state, at, excerpt, unread, entry_id? \| revision?}], next_cursor, unread_total, unavailable}`. The cursor is a position, valid after its item is read or deleted. Tasks pending deletion are left out; `unavailable` counts unreadable task records. The first page also has `cutoff: [{task_id, progress, progress_last, review}]` for every task with unread items, each captured from that task's own read (per task, not one instant across tasks). |
159
+ | `POST /contributions/read` | `{cutoff}` with at most 200 distinct tasks from a first page: reads each task's first `progress` entries and review revision `review`, never later ones, one task change each. `{results: [{task_id, status}]}` in request order, `status` `read` (with `read`: items that were unread and are read now), `changed` (the task no longer matches its cutoff; nothing written), `gone`, `deleting` or `failed`. |
158
160
 
159
161
  The HTTP API has no upload route: the browser never submits a file path.
160
162
 
@@ -30,6 +30,7 @@ complete internal worktree registries, including dirty and stale owned entries.
30
30
  | Operation | Workspace and execution artifacts | Member runtime evidence |
31
31
  | --- | --- | --- |
32
32
  | Complete, fail, cancel | Retained while Task is recorded | Retained in Fleet recovery archives |
33
+ | Explicit working-files removal (Done Task only) | Task workspace removed; Task, Progress and To review files retained | Retained in Fleet recovery archives |
33
34
  | Linked Room retirement or deletion | Task workspace retained | Retained |
34
35
  | Internal standalone Room retirement | Room workspace retained | Retained |
35
36
  | Explicit Task deletion | Removed after member retirement, with the task's retained To review files | All proven owned retired launches removed |
@@ -39,7 +40,8 @@ Internal room close means retirement, not destructive Task deletion.
39
40
  The existing CLI `room close ID ID` remains a deprecated alias for explicit
40
41
  `room delete ID ID`: it deletes standalone room-owned artifacts, but always
41
42
  retains a linked Task workspace. Its repeated-ID confirmation is unchanged. To remove a Task's
42
- artifacts, explicitly delete the Task. Cancellation is not deletion. Automatic
43
+ artifacts, explicitly delete the Task, or, for a Done Task, remove only its working files
44
+ (`task remove-files ID`, below). Cancellation is not deletion. Automatic
43
45
  compatibility cleanup of legacy closed Rooms skips records with owned workspaces.
44
46
  These choices reconcile close/remove wording with the requirement that completed,
45
47
  recorded Tasks retain all artifacts.
@@ -223,3 +225,51 @@ requires a supported Home Screen installation and granted device/browser permiss
223
225
  reopen the workspace to reconcile an expired subscription and explicitly re-enable
224
226
  permission if it was revoked. Every received push must display a notification to
225
227
  honor Safari's user-visible push requirement.
228
+
229
+ ## Removing a Done Task's working files
230
+
231
+ `ours-fleet task remove-files ID` (API `POST /api/v1/tasks/ID/working-files/remove`)
232
+ frees disk space without deleting the Task. It removes only the recorded owned
233
+ workspace through the same guarded deletion as Task deletion (marker, Git
234
+ registration, symlink and mount checks, token tombstone). It never collects or
235
+ erases recovery archives, member artifacts or the To review store, and never
236
+ closes rooms or stops agents. It runs under the task operation lock and refuses
237
+ with the reason unless all of these hold:
238
+
239
+ - the Task is Done with its completion settled, and is not being deleted;
240
+ - no room record names the Task or its workspace, and every room record is readable;
241
+ - a layout run exists, is closed, is not uncertain, and has retired every owned participant;
242
+ - no supervisor registration belongs to the Task;
243
+ - no unarchived temporary agent state belongs to the Task or works in the
244
+ folder (cwd or startup workspace, literally or through symlinks), and all
245
+ such state is readable;
246
+ - no permanent agent's recorded `.cwd` or configured cwd is in the folder.
247
+
248
+ The intent is recorded before a final in-use scan. Agent state that names a
249
+ task folder is created under that folder's use lock and refuses a folder
250
+ whose removal began, so a concurrent launch is either seen by the scan or
251
+ refused. After the folder is renamed to its tombstone and before anything
252
+ is removed, every process's cwd, root and open files are checked (Linux
253
+ `/proc`). A process that entered the folder earlier restores the folder and
254
+ refuses the removal. Platforms without this check refuse the removal.
255
+ A same-user process whose cwd and descriptors are unreadable (for example
256
+ non-dumpable, or in a container) refuses the removal unless it is a zombie.
257
+ Nothing readable proves such a process holds nothing in the folder: a
258
+ retained cwd or descriptor survives namespace and chroot changes, and a user
259
+ service manager may keep descriptors in its store. The refusal names each
260
+ such process by pid and kernel name (never its command line), with
261
+ read-only context such as "own mount namespace without the folder".
262
+ Another user's unreadable process is skipped when a private ancestor closes
263
+ the folder to it. Superuser processes are outside the check. On hosts that
264
+ run rootless containers or a user service manager, this strict check can
265
+ make removal unavailable while those processes run. Only a folder
266
+ that this run renamed intact is restored. A partly removed tombstone from an
267
+ earlier run stays in place with the refusal recorded, so a later retry can
268
+ finish it.
269
+
270
+ The Task records `workspace_removal` (started, completed, error), shown as
271
+ `working_files` in API payloads. A retry resumes an unfinished tombstone, and
272
+ repeating a finished removal changes nothing. A folder already missing on the
273
+ first run is recorded as such. A create replay never recreates the folder, and
274
+ To review files can no longer be attached from it. Explicit Task deletion
275
+ still works and first finishes an interrupted removal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",