@ours.network/fleet 1.2.0-nightly.2 → 1.2.0-nightly.20
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/README.md +152 -29
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +72 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +59 -0
- package/dist/agent-ours/runtime.js +283 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +372 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/agent-recovery-gate.js +1 -1
- package/dist/application/conversation-history.d.ts +6 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/model-catalog.d.ts +1 -1
- package/dist/application/model-catalog.js +5 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +8 -0
- package/dist/application/session-control.js +21 -3
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +113 -0
- package/dist/application/task-layouts.d.ts +89 -0
- package/dist/application/task-layouts.js +248 -0
- package/dist/application/task-room-service.d.ts +99 -3
- package/dist/application/task-room-service.js +296 -31
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +5 -4
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +65 -2
- package/dist/client-profile.d.ts +5 -16
- package/dist/client-profile.js +4 -112
- package/dist/config.d.ts +4 -0
- package/dist/config.js +7 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +5 -16
- package/dist/daemon-recovery.d.ts +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +43 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +46 -59
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +1 -0
- package/dist/fleet-command-audit.js +12 -7
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-app-server-proxy.d.ts +4 -1
- package/dist/harness/codex-app-server-proxy.js +41 -5
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +4 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/init-wizard.js +19 -9
- package/dist/monitor.d.ts +1 -34
- package/dist/monitor.js +24 -151
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +34 -6
- package/dist/owner-channel/commands.d.ts +4 -0
- package/dist/owner-channel/commands.js +8 -0
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/preset-migration.js +15 -0
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +34 -5
- package/dist/rooms-tasks/close.d.ts +5 -2
- package/dist/rooms-tasks/close.js +208 -28
- package/dist/rooms-tasks/config.js +3 -1
- package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
- package/dist/rooms-tasks/cowork-adapter.js +11 -117
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +74 -5
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -4
- package/dist/rooms-tasks/layout-cli.d.ts +2 -0
- package/dist/rooms-tasks/layout-cli.js +81 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +39 -0
- package/dist/rooms-tasks/layout-service.js +173 -0
- package/dist/rooms-tasks/layout.d.ts +102 -0
- package/dist/rooms-tasks/layout.js +227 -0
- package/dist/rooms-tasks/live-readiness.d.ts +18 -0
- package/dist/rooms-tasks/live-readiness.js +85 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +14 -0
- package/dist/rooms-tasks/provision.js +182 -48
- package/dist/rooms-tasks/room-state.d.ts +4 -2
- package/dist/rooms-tasks/room-state.js +53 -8
- package/dist/rooms-tasks/task-state.d.ts +42 -7
- package/dist/rooms-tasks/task-state.js +153 -23
- package/dist/rooms-tasks/types.d.ts +28 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +23 -3
- package/dist/runner.js +574 -494
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +7 -0
- package/dist/session/arbiter.d.ts +3 -0
- package/dist/session/arbiter.js +20 -2
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/session/codex-app-server.d.ts +3 -0
- package/dist/session/codex-app-server.js +25 -1
- package/dist/session/control.d.ts +10 -1
- package/dist/session/control.js +36 -2
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-types.d.ts +5 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/supervisor/launchd.js +2 -1
- package/dist/supervisor/systemd.js +9 -23
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +6 -1
- package/dist/temp-lifecycle.js +29 -13
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +4 -1
- package/dist/web/auth.js +23 -2
- package/dist/web/control.js +2 -1
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +20 -0
- package/dist/web/prefix-gateway.js +150 -0
- package/dist/web/runtime.d.ts +4 -0
- package/dist/web/runtime.js +19 -14
- package/dist/web/server.d.ts +7 -0
- package/dist/web/server.js +293 -25
- package/docs/room-layouts.md +92 -0
- package/docs/task-workspaces.md +130 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +17 -13
- package/presets/README.md +66 -0
- package/presets/brain-catalog.json +7 -2
- package/presets/fleet/agent_templates/Engineer.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +22 -0
- package/presets/fleet/roles/Engineer.yaml +40 -0
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/presets/fleet/room_templates/engineering.yaml +19 -0
- package/presets/manifest.json +2 -2
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
5
|
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { planWorkspace, ensureWorkspace } from './workspace.js';
|
|
6
7
|
import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
|
|
7
8
|
import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
|
|
8
9
|
export const tasksDir = () => join(stateRoot(), 'tasks');
|
|
@@ -125,21 +126,32 @@ export function assertNoPendingDeletion(task) {
|
|
|
125
126
|
throw new TaskStateError(`task ${task.task_id} is pending deletion; run 'ours-fleet task delete ${task.task_id} ${task.task_id}' to retry cleanup`);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
129
|
+
function creationPlan(plan, layout) {
|
|
130
|
+
return JSON.stringify([plan?.plan_hash ?? null, storedRoomLaunchPolicy(plan?.room_policy) ?? null,
|
|
131
|
+
layout ? `${layout.name}@${layout.definition_hash}` : null]);
|
|
132
|
+
}
|
|
128
133
|
export function createTask(input) {
|
|
129
134
|
const key = input.idempotency_key ?? randomUUID();
|
|
130
|
-
const existing =
|
|
135
|
+
const existing = listTasks({ includeDeleting: true }).find(task => task.idempotency_key === key);
|
|
136
|
+
const requestedPlan = creationPlan(input.execution_plan, input.layout);
|
|
131
137
|
if (existing) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const existingPolicy = storedRoomLaunchPolicy(existing.execution_plan?.room_policy);
|
|
135
|
-
const requestedPolicy = storedRoomLaunchPolicy(input.execution_plan?.room_policy);
|
|
136
|
-
if (existingPlan !== requestedPlan || JSON.stringify(existingPolicy) !== JSON.stringify(requestedPolicy))
|
|
138
|
+
// After a Backlog plan edit the current plan differs from the one created; retries compare the original request.
|
|
139
|
+
if ((existing.creation_plan ?? creationPlan(existing.execution_plan, existing.layout)) !== requestedPlan)
|
|
137
140
|
throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
|
|
138
|
-
return existing
|
|
141
|
+
return withTaskLock(existing.task_id, () => {
|
|
142
|
+
const fresh = readTask(existing.task_id);
|
|
143
|
+
assertNoPendingDeletion(fresh);
|
|
144
|
+
if (fresh.workspace)
|
|
145
|
+
ensureWorkspace(fresh.workspace);
|
|
146
|
+
return fresh;
|
|
147
|
+
});
|
|
139
148
|
}
|
|
149
|
+
if (input.layout && (input.execution_plan || input.no_room || input.room_id))
|
|
150
|
+
throw new TaskStateError('a layout task cannot also use a room template, no-room mode or a room');
|
|
140
151
|
const state = input.start === false ? 'backlog' : 'provisioning';
|
|
152
|
+
const taskId = generateTaskId();
|
|
141
153
|
const record = {
|
|
142
|
-
task_id:
|
|
154
|
+
task_id: taskId,
|
|
143
155
|
list_id: input.listId ?? DEFAULT_TASK_LIST_ID,
|
|
144
156
|
title: input.title,
|
|
145
157
|
brief: input.brief,
|
|
@@ -148,14 +160,18 @@ export function createTask(input) {
|
|
|
148
160
|
template: input.template,
|
|
149
161
|
execution_plan: input.execution_plan,
|
|
150
162
|
no_room: input.no_room || undefined,
|
|
163
|
+
...(input.layout ? { layout: { name: input.layout.name, run_id: `task-${taskId}`, definition_hash: input.layout.definition_hash } } : {}),
|
|
151
164
|
room_id: input.room_id,
|
|
152
165
|
member_roles: [],
|
|
153
166
|
origin: input.origin,
|
|
154
167
|
idempotency_key: key,
|
|
168
|
+
creation_plan: requestedPlan,
|
|
155
169
|
created_at: new Date().toISOString(),
|
|
156
170
|
started_at: state === 'provisioning' ? new Date().toISOString() : undefined,
|
|
157
171
|
};
|
|
172
|
+
record.workspace = planWorkspace('task', record.task_id);
|
|
158
173
|
writeTask(record);
|
|
174
|
+
ensureWorkspace(record.workspace);
|
|
159
175
|
return readTask(record.task_id);
|
|
160
176
|
}
|
|
161
177
|
export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
|
|
@@ -505,7 +521,8 @@ function writeDeletionReceiptForIntent(stored) {
|
|
|
505
521
|
actor: deletion.actor,
|
|
506
522
|
original_state: stored.state,
|
|
507
523
|
room_id: deletion.room_id,
|
|
508
|
-
member_count: deletion.members.length,
|
|
524
|
+
member_count: deletion.members.length + (deletion.archived_absences?.length ?? 0),
|
|
525
|
+
...(deletion.archived_absences?.length ? { archived_absences: deletion.archived_absences } : {}),
|
|
509
526
|
};
|
|
510
527
|
replaceFileAtomically(deletionReceiptPath(stored.task_id), JSON.stringify(receipt, null, 2) + '\n');
|
|
511
528
|
}
|
|
@@ -525,14 +542,10 @@ export function ensureTaskDeletionReceipt(id) {
|
|
|
525
542
|
writeDeletionReceiptForIntent(stored);
|
|
526
543
|
});
|
|
527
544
|
}
|
|
528
|
-
/**
|
|
545
|
+
/** Erase the in-flight receipt; also heals a crash after task unlink. */
|
|
529
546
|
export function completeTaskDeletionReceipt(id) {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
return;
|
|
533
|
-
receipt.settled_at = new Date().toISOString();
|
|
534
|
-
receipt.result = 'deleted';
|
|
535
|
-
replaceFileAtomically(deletionReceiptPath(id), JSON.stringify(receipt, null, 2) + '\n');
|
|
547
|
+
assertCanonicalTaskId(id);
|
|
548
|
+
rmSync(deletionReceiptPath(id), { force: true });
|
|
536
549
|
}
|
|
537
550
|
/**
|
|
538
551
|
* Persist the first-wins durable deletion intent. Accepts every lifecycle
|
|
@@ -551,8 +564,10 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
551
564
|
stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
552
565
|
}
|
|
553
566
|
catch (error) {
|
|
554
|
-
if (isNotFoundError(error))
|
|
567
|
+
if (isNotFoundError(error)) {
|
|
568
|
+
completeTaskDeletionReceipt(id);
|
|
555
569
|
return { status: 'already_absent' };
|
|
570
|
+
}
|
|
556
571
|
throw error;
|
|
557
572
|
}
|
|
558
573
|
if (stored.deletion?.status === 'pending') {
|
|
@@ -688,10 +703,10 @@ export function upsertTaskDeletionMembersFromSeats(id, seats) {
|
|
|
688
703
|
*
|
|
689
704
|
* The room saga is trusted for phase jumps, but partial or corrupt retained
|
|
690
705
|
* records must not become false success: every seat must be identity_absent;
|
|
691
|
-
* a real launch requires archive evidence; an identity-less seat
|
|
692
|
-
*
|
|
706
|
+
* a real launch requires archive evidence; an identity-less seat requires
|
|
707
|
+
* never-launched proof or a freshly verified archived-absence checkpoint.
|
|
693
708
|
*/
|
|
694
|
-
export function importTaskDeletionRetirementEvidence(id, seats) {
|
|
709
|
+
export function importTaskDeletionRetirementEvidence(id, seats, proofs = []) {
|
|
695
710
|
return withTaskLock(id, () => {
|
|
696
711
|
assertCanonicalTaskId(id);
|
|
697
712
|
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
@@ -702,12 +717,29 @@ export function importTaskDeletionRetirementEvidence(id, seats) {
|
|
|
702
717
|
const evidence = seat.retirement;
|
|
703
718
|
if (evidence?.phase !== 'identity_absent')
|
|
704
719
|
throw new TaskStateError(`task ${id} deletion cannot checkpoint member '${seat.role_name}' before completed retirement`);
|
|
705
|
-
const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED
|
|
720
|
+
const neverLaunched = evidence.launch_id === DELETION_MEMBER_NEVER_LAUNCHED
|
|
721
|
+
|| evidence.launch_id === DELETION_MEMBER_ABSENT_VERIFIED || evidence.absence_verified === true;
|
|
706
722
|
if (!neverLaunched && evidence.archive_path === undefined)
|
|
707
723
|
throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' retirement evidence lacks its archive`);
|
|
708
724
|
if (!seat.identity_cid) {
|
|
709
|
-
if (neverLaunched)
|
|
710
|
-
|
|
725
|
+
if (neverLaunched) {
|
|
726
|
+
stored.deletion.absent_members ??= [];
|
|
727
|
+
if (!stored.deletion.absent_members.includes(seat.role_name))
|
|
728
|
+
stored.deletion.absent_members.push(seat.role_name);
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
const proof = proofs.find(value => value.name === seat.role_name);
|
|
732
|
+
if (proof && proof.launch_id === evidence.launch_id && proof.archive_path === evidence.archive_path
|
|
733
|
+
&& proof.launch_id === seat.launch?.launch_id && proof.action_id === seat.launch?.action_id) {
|
|
734
|
+
const existing = stored.deletion.archived_absences?.find(value => value.name === proof.name);
|
|
735
|
+
if (existing && (existing.launch_id !== proof.launch_id || existing.action_id !== proof.action_id
|
|
736
|
+
|| existing.archive_path !== proof.archive_path))
|
|
737
|
+
throw new TaskStateError('Archived absence checkpoint ownership is immutable');
|
|
738
|
+
stored.deletion.archived_absences ??= [];
|
|
739
|
+
if (!existing)
|
|
740
|
+
stored.deletion.archived_absences.push({ ...proof });
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
711
743
|
throw new TaskStateError(`task ${id} deletion member '${seat.role_name}' has retirement evidence but no identity CID`);
|
|
712
744
|
}
|
|
713
745
|
let cursor = findCursorForSeat(id, stored, seat);
|
|
@@ -729,9 +761,23 @@ export function importTaskDeletionRetirementEvidence(id, seats) {
|
|
|
729
761
|
cursor.updated_at = now;
|
|
730
762
|
}
|
|
731
763
|
writeTask(stored);
|
|
764
|
+
writeDeletionReceiptForIntent(stored);
|
|
732
765
|
return presentTaskLenient(stored);
|
|
733
766
|
});
|
|
734
767
|
}
|
|
768
|
+
/** Settlement-only checkpoint, under the task-operation lock after fresh archive verification.
|
|
769
|
+
* From here retries still verify live absence, but owned archives may be consumed.
|
|
770
|
+
*/
|
|
771
|
+
export function beginTaskWorkspaceCleanup(id) {
|
|
772
|
+
withTaskLock(id, () => {
|
|
773
|
+
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
774
|
+
if (stored.deletion?.status !== 'pending'
|
|
775
|
+
|| stored.deletion.members.some(member => member.phase !== 'identity_absent'))
|
|
776
|
+
throw new TaskStateError('Task is not ready for workspace cleanup');
|
|
777
|
+
stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
|
|
778
|
+
writeTask(stored);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
735
781
|
/**
|
|
736
782
|
* Physically remove a deletion-pending task record. Settlement-only: callers
|
|
737
783
|
* must have completed member retirement and room cleanup first. Missing
|
|
@@ -762,3 +808,87 @@ export function unlinkDeletedTask(id) {
|
|
|
762
808
|
return true;
|
|
763
809
|
});
|
|
764
810
|
}
|
|
811
|
+
/** Called only after current temp and exact identity absence have been checked. */
|
|
812
|
+
export function settleAbsentTaskDeletionMember(id, name) {
|
|
813
|
+
withTaskLock(id, () => {
|
|
814
|
+
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
815
|
+
if (stored.deletion?.status !== 'pending')
|
|
816
|
+
throw new TaskStateError('Task is not deleting');
|
|
817
|
+
const member = stored.deletion.members.find(value => value.name === name);
|
|
818
|
+
if (!member)
|
|
819
|
+
throw new TaskStateError('Unknown deletion member');
|
|
820
|
+
member.phase = 'identity_absent';
|
|
821
|
+
member.launch_id ??= DELETION_MEMBER_ABSENT_VERIFIED;
|
|
822
|
+
member.updated_at = new Date().toISOString();
|
|
823
|
+
writeTask(stored);
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
/** Room-only erasure keeps the parent Task but removes its dead room/member links. */
|
|
827
|
+
export function detachDeletedRoom(taskId, roomId) {
|
|
828
|
+
withTaskLock(taskId, () => {
|
|
829
|
+
if (!existsSync(taskPath(taskId)))
|
|
830
|
+
return;
|
|
831
|
+
const stored = JSON.parse(readFileSync(taskPath(taskId), 'utf8'));
|
|
832
|
+
if (stored.room_id !== roomId || stored.deletion?.status === 'pending')
|
|
833
|
+
return;
|
|
834
|
+
delete stored.room_id;
|
|
835
|
+
delete stored.room_identity_cid;
|
|
836
|
+
stored.member_roles = [];
|
|
837
|
+
writeTask(stored);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/** A completed room may consume its archives while other task members still retire. */
|
|
841
|
+
export function beginTaskArchiveCleanup(id) {
|
|
842
|
+
withTaskLock(id, () => {
|
|
843
|
+
const stored = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
844
|
+
if (stored.deletion?.status !== 'pending')
|
|
845
|
+
throw new TaskStateError('Task is not deleting');
|
|
846
|
+
stored.deletion.workspace_cleanup_started_at ??= new Date().toISOString();
|
|
847
|
+
writeTask(stored);
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
/** Metadata-only edit; compare under the task lock to prevent lost updates. */
|
|
851
|
+
/**
|
|
852
|
+
* Choose, change or clear a Backlog task's Room Layout before it starts.
|
|
853
|
+
* `expected` is the layout name the caller saw (null = none); a different
|
|
854
|
+
* current choice is a stale edit. Choosing a layout drops the sealed room
|
|
855
|
+
* template plan; the caller releases the returned launch snapshot hash.
|
|
856
|
+
*/
|
|
857
|
+
export function updateTaskLayout(id, expected, next) {
|
|
858
|
+
return withTaskLock(id, () => {
|
|
859
|
+
const task = readTask(id);
|
|
860
|
+
assertNoPendingDeletion(task);
|
|
861
|
+
assertNoPendingTerminalIntent(task);
|
|
862
|
+
if (task.state !== 'backlog' || task.room_id)
|
|
863
|
+
throw new TaskStateError(`task ${id} has started; its room layout is fixed (state '${task.state}')`);
|
|
864
|
+
const current = task.layout?.name ?? null;
|
|
865
|
+
// A replay of the same choice is idempotent (and refreshes the pinned source hash).
|
|
866
|
+
if (current !== expected && current !== (next?.name ?? null))
|
|
867
|
+
throw new TaskStateError(`task ${id} room layout changed to '${current ?? 'one room'}'; reload before changing it`);
|
|
868
|
+
const releasedSnapshot = next ? task.execution_plan?.snapshot.launch_snapshot_hash : undefined;
|
|
869
|
+
if (next) {
|
|
870
|
+
task.layout = { name: next.name, run_id: `task-${task.task_id}`, definition_hash: next.definition_hash };
|
|
871
|
+
delete task.execution_plan;
|
|
872
|
+
delete task.template;
|
|
873
|
+
delete task.no_room;
|
|
874
|
+
}
|
|
875
|
+
else
|
|
876
|
+
delete task.layout;
|
|
877
|
+
task.room_plan_changed_at = new Date().toISOString();
|
|
878
|
+
writeTask(task);
|
|
879
|
+
return { task: readTask(id), ...(releasedSnapshot ? { releasedSnapshot } : {}) };
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
export function updateTaskBrief(id, brief, expectedBrief) {
|
|
883
|
+
assertCanonicalTaskId(id);
|
|
884
|
+
return withTaskLock(id, () => {
|
|
885
|
+
const task = readTask(id);
|
|
886
|
+
assertNoPendingDeletion(task);
|
|
887
|
+
assertNoPendingTerminalIntent(task);
|
|
888
|
+
if ((task.brief ?? '') !== expectedBrief && task.brief !== brief)
|
|
889
|
+
throw new TaskStateError('Description changed elsewhere. Reload the task before saving.');
|
|
890
|
+
task.brief = brief;
|
|
891
|
+
writeTask(task);
|
|
892
|
+
return task;
|
|
893
|
+
});
|
|
894
|
+
}
|
|
@@ -73,6 +73,13 @@ export interface TaskDeletionMemberCursor {
|
|
|
73
73
|
* evidence. While pending, the task is hidden from normal operation and every
|
|
74
74
|
* lifecycle mutation or room publication is rejected.
|
|
75
75
|
*/
|
|
76
|
+
export interface ArchivedMemberAbsence {
|
|
77
|
+
name: string;
|
|
78
|
+
launch_id: string;
|
|
79
|
+
action_id: string;
|
|
80
|
+
archive_path: string;
|
|
81
|
+
checked_at: string;
|
|
82
|
+
}
|
|
76
83
|
export interface TaskDeletionIntent {
|
|
77
84
|
status: 'pending';
|
|
78
85
|
accepted_at: string;
|
|
@@ -80,13 +87,25 @@ export interface TaskDeletionIntent {
|
|
|
80
87
|
room_id?: string;
|
|
81
88
|
/** Snapshot of managed members at acceptance; the missing-room retirement evidence. */
|
|
82
89
|
members: TaskDeletionMemberCursor[];
|
|
90
|
+
archived_absences?: ArchivedMemberAbsence[];
|
|
91
|
+
absent_members?: string[];
|
|
92
|
+
/** Archive proofs were verified before owned workspace cleanup may consume them. */
|
|
93
|
+
workspace_cleanup_started_at?: string;
|
|
83
94
|
error?: string;
|
|
84
95
|
error_at?: string;
|
|
85
96
|
recovery_hint?: string;
|
|
86
97
|
first_failure?: string;
|
|
87
98
|
first_recovery_hint?: string;
|
|
88
99
|
}
|
|
100
|
+
export interface TaskLayoutLink {
|
|
101
|
+
name: string;
|
|
102
|
+
/** Deterministic `task-<task_id>` Room Layout run ID. */
|
|
103
|
+
run_id: string;
|
|
104
|
+
/** sha256 of the canonical source layout definition the run must snapshot. */
|
|
105
|
+
definition_hash: string;
|
|
106
|
+
}
|
|
89
107
|
export interface TaskRecord {
|
|
108
|
+
workspace?: import('./workspace.js').OwnedWorkspace;
|
|
90
109
|
task_id: string;
|
|
91
110
|
/** Stable organizational list identifier. Missing legacy values mean `default`. */
|
|
92
111
|
list_id: string;
|
|
@@ -107,6 +126,12 @@ export interface TaskRecord {
|
|
|
107
126
|
plan_hash: string;
|
|
108
127
|
};
|
|
109
128
|
no_room?: boolean;
|
|
129
|
+
/** Multi-room task: rooms and participants come from a Room Layout run. */
|
|
130
|
+
layout?: TaskLayoutLink;
|
|
131
|
+
/** Set when the room plan (layout or one-room template) was changed after creation, before start. */
|
|
132
|
+
room_plan_changed_at?: string;
|
|
133
|
+
/** Fingerprint of the room plan requested at creation; idempotent retries compare against it after later plan edits. */
|
|
134
|
+
creation_plan?: string;
|
|
110
135
|
room_id?: string;
|
|
111
136
|
room_identity_cid?: string;
|
|
112
137
|
member_roles: TaskMemberRole[];
|
|
@@ -201,6 +226,7 @@ export type RoomHistoryEvidence = {
|
|
|
201
226
|
};
|
|
202
227
|
export type MemberRetirementPhase = 'stop_requested' | 'liveness_absent' | 'archive_secured' | 'identity_absent';
|
|
203
228
|
export interface MemberRetirement {
|
|
229
|
+
absence_verified?: boolean;
|
|
204
230
|
phase: MemberRetirementPhase;
|
|
205
231
|
launch_id: string;
|
|
206
232
|
updated_at: string;
|
|
@@ -227,12 +253,14 @@ export interface RoomMemberSeat {
|
|
|
227
253
|
retirement?: MemberRetirement;
|
|
228
254
|
}
|
|
229
255
|
export interface RoomOrchestrationRecord {
|
|
256
|
+
workspace?: import('./workspace.js').OwnedWorkspace;
|
|
230
257
|
room_id: string;
|
|
231
258
|
room_identity_cid?: string;
|
|
232
259
|
room_name: string;
|
|
233
260
|
goal?: string;
|
|
234
261
|
task_id?: string;
|
|
235
262
|
template_snapshot?: TemplateSnapshot;
|
|
263
|
+
additional_member_templates?: TemplateSnapshot[];
|
|
236
264
|
/** Resolved once at launch; legacy absence means non-anonymous. */
|
|
237
265
|
room_policy?: RoomLaunchPolicy;
|
|
238
266
|
saga: SagaCursor;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type OwnedWorkspace } from './workspace.js';
|
|
2
|
+
/** Runtime control state stays in Fleet's live roster and recovery store. Its
|
|
3
|
+
* durable launch descriptor, not a name prefix or caller-supplied path, proves
|
|
4
|
+
* artifact ownership. Audit receipts/journals intentionally outlive deletion.
|
|
5
|
+
*/
|
|
6
|
+
export declare function collectWorkspaceArchives(workspace: OwnedWorkspace): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
import { assertSafeAncestors, auditWorkspaceGit, validateWorkspace, ensureWorkspace, WorkspaceError } from './workspace.js';
|
|
6
|
+
/** Runtime control state stays in Fleet's live roster and recovery store. Its
|
|
7
|
+
* durable launch descriptor, not a name prefix or caller-supplied path, proves
|
|
8
|
+
* artifact ownership. Audit receipts/journals intentionally outlive deletion.
|
|
9
|
+
*/
|
|
10
|
+
export function collectWorkspaceArchives(workspace) {
|
|
11
|
+
validateWorkspace(workspace);
|
|
12
|
+
const root = join(stateRoot(), 'recovery', 'temporary');
|
|
13
|
+
assertSafeAncestors(root);
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'ENOENT')
|
|
20
|
+
return;
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
const owned = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
26
|
+
continue;
|
|
27
|
+
const path = join(root, entry.name);
|
|
28
|
+
const roleFile = join(path, 'role.yaml');
|
|
29
|
+
let role;
|
|
30
|
+
try {
|
|
31
|
+
const s = lstatSync(roleFile);
|
|
32
|
+
if (!s.isFile() || s.isSymbolicLink())
|
|
33
|
+
continue;
|
|
34
|
+
role = parse(readFileSync(roleFile, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
} // no ownership proof: never adopt a foreign archive
|
|
39
|
+
const recorded = role?.roomMemberStartup?.workspace;
|
|
40
|
+
if (recorded?.owner !== workspace.owner || recorded.id !== workspace.id)
|
|
41
|
+
continue;
|
|
42
|
+
if (JSON.stringify(recorded) !== JSON.stringify(workspace))
|
|
43
|
+
throw new WorkspaceError('workspace archive ownership mismatch');
|
|
44
|
+
assertSafeAncestors(path);
|
|
45
|
+
const termination = join(path, 'termination.jsonl');
|
|
46
|
+
if (!lstatSync(termination).isFile() || lstatSync(termination).isSymbolicLink())
|
|
47
|
+
throw new WorkspaceError('unsafe workspace archive termination evidence');
|
|
48
|
+
const supervisorPath = join(path, '.temp-supervisor.json');
|
|
49
|
+
let supervisor;
|
|
50
|
+
try {
|
|
51
|
+
if (!lstatSync(supervisorPath).isFile() || lstatSync(supervisorPath).isSymbolicLink())
|
|
52
|
+
throw new WorkspaceError('unsafe workspace archive supervisor evidence');
|
|
53
|
+
supervisor = JSON.parse(readFileSync(supervisorPath, 'utf8'));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
if (e.code !== 'ENOENT')
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
if (supervisor && (supervisor.role !== role.name || !supervisor.launchId))
|
|
60
|
+
throw new WorkspaceError('workspace archive launch ownership mismatch');
|
|
61
|
+
const terminated = readFileSync(termination, 'utf8').trim().split('\n').some(line => {
|
|
62
|
+
const value = JSON.parse(line);
|
|
63
|
+
return value.version === 1 && value.role === role.name
|
|
64
|
+
&& value.launchId === supervisor?.launchId
|
|
65
|
+
&& ['retired', 'reclaimed', 'failed'].includes(value.outcome);
|
|
66
|
+
});
|
|
67
|
+
if (!terminated || entry.name.endsWith('.retiring'))
|
|
68
|
+
throw new WorkspaceError('workspace archive retirement is incomplete');
|
|
69
|
+
auditWorkspaceGit(path);
|
|
70
|
+
owned.push(path);
|
|
71
|
+
}
|
|
72
|
+
// Preflight the entire set before mutation; task/room record remains until all succeed.
|
|
73
|
+
if (!owned.length)
|
|
74
|
+
return;
|
|
75
|
+
ensureWorkspace(workspace);
|
|
76
|
+
const destination = join(workspace.path, '.fleet-retired-agents');
|
|
77
|
+
assertSafeAncestors(destination);
|
|
78
|
+
mkdirSync(destination, { recursive: true, mode: 0o700 });
|
|
79
|
+
for (const path of owned) {
|
|
80
|
+
const target = join(destination, basename(path));
|
|
81
|
+
try {
|
|
82
|
+
lstatSync(target);
|
|
83
|
+
throw new WorkspaceError('archive collection destination exists');
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
if (e.code !== 'ENOENT')
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
// Atomic transfer before workspace deletion: a crash never loses ownership
|
|
90
|
+
// evidence halfway through recursively removing a global recovery archive.
|
|
91
|
+
renameSync(path, target);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Persisted before allocation. Never infer ownership from an Agent's cwd. */
|
|
2
|
+
export interface OwnedWorkspace {
|
|
3
|
+
version: 1;
|
|
4
|
+
owner: 'task' | 'room';
|
|
5
|
+
id: string;
|
|
6
|
+
path: string;
|
|
7
|
+
token: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class WorkspaceError extends Error {
|
|
10
|
+
}
|
|
11
|
+
export declare function assertSafeAncestors(path: string): void;
|
|
12
|
+
export declare function planWorkspace(owner: OwnedWorkspace['owner'], id: string): OwnedWorkspace;
|
|
13
|
+
export declare function validateWorkspace(w: OwnedWorkspace, owner?: "task" | "room", id?: string): void;
|
|
14
|
+
/** Atomic directory publication; retry uses the durable token, never adopts another folder. */
|
|
15
|
+
export declare function ensureWorkspace(w: OwnedWorkspace): string;
|
|
16
|
+
/** Audit both directions of Git worktree registration, without executing repository config/hooks.
|
|
17
|
+
* All registrations and common dirs must be inside this workspace. Removing the complete
|
|
18
|
+
* workspace then removes their registries atomically with the owned repositories; never prune.
|
|
19
|
+
*/
|
|
20
|
+
export declare function auditWorkspaceGit(path: string): void;
|
|
21
|
+
/** Called only after all managed writers retire, under the owner's lifecycle lock.
|
|
22
|
+
* The tombstone is derived from the durable unguessable token. A crash during rm
|
|
23
|
+
* resumes that exact tombstone even if rm already removed its marker.
|
|
24
|
+
*/
|
|
25
|
+
export declare function assertWorkspaceDeletable(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
|
|
26
|
+
export declare function deleteWorkspace(w: OwnedWorkspace, owner: OwnedWorkspace['owner'], id: string): void;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
const MARKER = '.fleet-workspace.json';
|
|
6
|
+
export class WorkspaceError extends Error {
|
|
7
|
+
}
|
|
8
|
+
function fail(message) { throw new WorkspaceError(`workspace: ${message}`); }
|
|
9
|
+
function stat(path) {
|
|
10
|
+
try {
|
|
11
|
+
return lstatSync(path);
|
|
12
|
+
}
|
|
13
|
+
catch (e) {
|
|
14
|
+
if (e.code === 'ENOENT')
|
|
15
|
+
return undefined;
|
|
16
|
+
throw e;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function assertSafeAncestors(path) {
|
|
20
|
+
const absolute = resolve(path);
|
|
21
|
+
let part = absolute;
|
|
22
|
+
for (;;) {
|
|
23
|
+
const s = stat(part);
|
|
24
|
+
if (s && (s.isSymbolicLink() || !s.isDirectory()))
|
|
25
|
+
fail(`unsafe directory ${part}`);
|
|
26
|
+
const parent = dirname(part);
|
|
27
|
+
if (parent === part)
|
|
28
|
+
break;
|
|
29
|
+
part = parent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function root(owner) {
|
|
33
|
+
return resolve(stateRoot(), 'workspaces', owner === 'task' ? 'tasks' : 'rooms');
|
|
34
|
+
}
|
|
35
|
+
export function planWorkspace(owner, id) {
|
|
36
|
+
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id))
|
|
37
|
+
fail('invalid owner ID');
|
|
38
|
+
return { version: 1, owner, id, path: join(root(owner), id), token: randomUUID() };
|
|
39
|
+
}
|
|
40
|
+
export function validateWorkspace(w, owner = w.owner, id = w.id) {
|
|
41
|
+
if (w.version !== 1 || !['task', 'room'].includes(w.owner) || w.owner !== owner || w.id !== id
|
|
42
|
+
|| !/^[a-zA-Z0-9_-]{1,80}$/.test(w.id)
|
|
43
|
+
|| !/^[a-f0-9-]{36}$/.test(w.token) || !isAbsolute(w.path)
|
|
44
|
+
|| w.path !== join(root(w.owner), w.id))
|
|
45
|
+
fail('invalid recorded ownership/path');
|
|
46
|
+
assertSafeAncestors(dirname(w.path));
|
|
47
|
+
}
|
|
48
|
+
function marked(path, w) {
|
|
49
|
+
assertSafeAncestors(path);
|
|
50
|
+
const marker = join(path, MARKER);
|
|
51
|
+
if (!stat(marker)?.isFile() || stat(marker)?.isSymbolicLink())
|
|
52
|
+
fail('missing/unsafe ownership marker');
|
|
53
|
+
const actual = JSON.parse(readFileSync(marker, 'utf8'));
|
|
54
|
+
if (JSON.stringify(actual) !== JSON.stringify(w))
|
|
55
|
+
fail('ownership marker mismatch');
|
|
56
|
+
}
|
|
57
|
+
function staging(w, phase) {
|
|
58
|
+
return join(dirname(w.path), `.${w.id}.${w.token}.${phase}`);
|
|
59
|
+
}
|
|
60
|
+
/** Atomic directory publication; retry uses the durable token, never adopts another folder. */
|
|
61
|
+
export function ensureWorkspace(w) {
|
|
62
|
+
validateWorkspace(w);
|
|
63
|
+
if (stat(staging(w, 'deleting')))
|
|
64
|
+
fail('deletion already started');
|
|
65
|
+
if (stat(w.path)) {
|
|
66
|
+
marked(w.path, w);
|
|
67
|
+
return w.path;
|
|
68
|
+
}
|
|
69
|
+
mkdirSync(dirname(w.path), { recursive: true, mode: 0o700 });
|
|
70
|
+
assertSafeAncestors(dirname(w.path));
|
|
71
|
+
const stage = staging(w, 'provisioning');
|
|
72
|
+
if (!stat(stage))
|
|
73
|
+
mkdirSync(stage, { mode: 0o700 });
|
|
74
|
+
assertSafeAncestors(stage);
|
|
75
|
+
if (readdirSync(stage).length === 0)
|
|
76
|
+
writeFileSync(join(stage, MARKER), JSON.stringify(w), { flag: 'wx', mode: 0o600 });
|
|
77
|
+
marked(stage, w);
|
|
78
|
+
try {
|
|
79
|
+
renameSync(stage, w.path);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
// Another allocator may have published this exact durable intent.
|
|
83
|
+
if (!stat(w.path))
|
|
84
|
+
throw error;
|
|
85
|
+
marked(w.path, w);
|
|
86
|
+
}
|
|
87
|
+
return w.path;
|
|
88
|
+
}
|
|
89
|
+
function inside(base, path) {
|
|
90
|
+
const rel = relative(base, path);
|
|
91
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
92
|
+
}
|
|
93
|
+
function ownedPointer(base, from, value) {
|
|
94
|
+
if (!value.trim() || /[\r\n]/.test(value.trim()))
|
|
95
|
+
fail('malformed git pointer');
|
|
96
|
+
const target = resolve(from, value.trim());
|
|
97
|
+
if (!inside(base, target))
|
|
98
|
+
fail(`foreign git metadata ${target}`);
|
|
99
|
+
assertSafeAncestors(dirname(target));
|
|
100
|
+
if (stat(target)?.isSymbolicLink())
|
|
101
|
+
fail(`symlink git metadata ${target}`);
|
|
102
|
+
return target;
|
|
103
|
+
}
|
|
104
|
+
/** Audit both directions of Git worktree registration, without executing repository config/hooks.
|
|
105
|
+
* All registrations and common dirs must be inside this workspace. Removing the complete
|
|
106
|
+
* workspace then removes their registries atomically with the owned repositories; never prune.
|
|
107
|
+
*/
|
|
108
|
+
export function auditWorkspaceGit(path) {
|
|
109
|
+
const device = lstatSync(path).dev;
|
|
110
|
+
const visit = (dir, admin = false) => {
|
|
111
|
+
admin ||= Boolean(stat(join(dir, 'HEAD')) && (stat(join(dir, 'objects')) || stat(join(dir, 'commondir'))));
|
|
112
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
113
|
+
const p = join(dir, entry.name);
|
|
114
|
+
if (entry.isSymbolicLink()) {
|
|
115
|
+
if (admin || entry.name === '.git' || entry.name === 'commondir' || entry.name === 'gitdir')
|
|
116
|
+
fail(`symlink git metadata ${p}`);
|
|
117
|
+
continue; // rm unlinks ordinary artifact symlinks, never follows them.
|
|
118
|
+
}
|
|
119
|
+
if (entry.isDirectory()) {
|
|
120
|
+
if (lstatSync(p).dev !== device)
|
|
121
|
+
fail(`foreign mounted directory ${p}`);
|
|
122
|
+
visit(p, admin || entry.name === '.git');
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!entry.isFile())
|
|
126
|
+
continue; // sockets/FIFOs in retired runtime state are unlinked, never opened
|
|
127
|
+
if (entry.name === '.git') {
|
|
128
|
+
const value = readFileSync(p, 'utf8').trim();
|
|
129
|
+
if (!value.startsWith('gitdir: '))
|
|
130
|
+
fail(`invalid git file ${p}`);
|
|
131
|
+
const gitdir = ownedPointer(path, dir, value.slice(8));
|
|
132
|
+
if (!stat(gitdir)?.isDirectory())
|
|
133
|
+
fail(`missing git directory ${gitdir}`);
|
|
134
|
+
}
|
|
135
|
+
// Git admin files, including bare repositories and submodules.
|
|
136
|
+
if (entry.name === 'commondir' && (admin || stat(join(dir, 'HEAD')))) {
|
|
137
|
+
const common = ownedPointer(path, dir, readFileSync(p, 'utf8'));
|
|
138
|
+
if (!stat(common)?.isDirectory())
|
|
139
|
+
fail(`invalid git common directory ${common}`);
|
|
140
|
+
}
|
|
141
|
+
if (entry.name === 'gitdir' && (admin || stat(join(dir, 'commondir')))) {
|
|
142
|
+
const target = ownedPointer(path, dir, readFileSync(p, 'utf8'));
|
|
143
|
+
if (stat(target) && !stat(target)?.isFile())
|
|
144
|
+
fail(`invalid worktree backlink ${target}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
visit(path);
|
|
149
|
+
}
|
|
150
|
+
/** Called only after all managed writers retire, under the owner's lifecycle lock.
|
|
151
|
+
* The tombstone is derived from the durable unguessable token. A crash during rm
|
|
152
|
+
* resumes that exact tombstone even if rm already removed its marker.
|
|
153
|
+
*/
|
|
154
|
+
export function assertWorkspaceDeletable(w, owner, id) {
|
|
155
|
+
validateWorkspace(w, owner, id);
|
|
156
|
+
if (stat(w.path)) {
|
|
157
|
+
if (stat(staging(w, 'deleting')))
|
|
158
|
+
fail('both live and deleting workspace exist');
|
|
159
|
+
marked(w.path, w);
|
|
160
|
+
auditWorkspaceGit(w.path);
|
|
161
|
+
}
|
|
162
|
+
const pending = staging(w, 'provisioning');
|
|
163
|
+
if (stat(pending)) {
|
|
164
|
+
assertSafeAncestors(pending);
|
|
165
|
+
if (readdirSync(pending).length)
|
|
166
|
+
marked(pending, w);
|
|
167
|
+
if (readdirSync(pending).some(name => name !== MARKER))
|
|
168
|
+
fail('unexpected provisioning artifacts');
|
|
169
|
+
}
|
|
170
|
+
if (stat(staging(w, 'deleting')))
|
|
171
|
+
assertSafeAncestors(staging(w, 'deleting'));
|
|
172
|
+
}
|
|
173
|
+
export function deleteWorkspace(w, owner, id) {
|
|
174
|
+
assertWorkspaceDeletable(w, owner, id);
|
|
175
|
+
const tomb = staging(w, 'deleting');
|
|
176
|
+
const pending = staging(w, 'provisioning');
|
|
177
|
+
if (stat(w.path)) {
|
|
178
|
+
if (stat(tomb))
|
|
179
|
+
fail('both live and deleting workspace exist');
|
|
180
|
+
marked(w.path, w);
|
|
181
|
+
auditWorkspaceGit(w.path);
|
|
182
|
+
renameSync(w.path, tomb);
|
|
183
|
+
}
|
|
184
|
+
if (stat(pending)) {
|
|
185
|
+
if (readdirSync(pending).length)
|
|
186
|
+
marked(pending, w);
|
|
187
|
+
if (readdirSync(pending).some(name => name !== MARKER))
|
|
188
|
+
fail('unexpected provisioning artifacts');
|
|
189
|
+
rmSync(pending, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
if (stat(tomb)) {
|
|
192
|
+
assertSafeAncestors(tomb);
|
|
193
|
+
rmSync(tomb, { recursive: true });
|
|
194
|
+
}
|
|
195
|
+
}
|