granttap-mcp 0.8.4 → 0.8.6

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.
@@ -13,6 +13,9 @@ import type { UserAttachment, UserAttachmentUpload } from "../../../packages/pro
13
13
  import { configDir } from "./config";
14
14
 
15
15
  export const ATTACHMENT_TTL_MS = 2 * 60 * 60_000;
16
+ /** How many attachments may wait for their messages at once, and how much disk they may take. */
17
+ export const MAX_STAGED_ATTACHMENTS = 32;
18
+ export const MAX_STAGED_BYTES = 48 * 1_024 * 1_024;
16
19
 
17
20
  function directory(): string {
18
21
  const dir = join(configDir(), "attachments");
@@ -39,10 +42,44 @@ export function storeAttachment(upload: UserAttachmentUpload, room?: string, now
39
42
  name: upload.name, mimeType: upload.mimeType, data: upload.data, receivedAt: now,
40
43
  ...(room ? { room } : {}),
41
44
  };
42
- writeFileSync(join(dir, `${id}.json`), JSON.stringify(record), { mode: 0o600 });
45
+ const body = JSON.stringify(record);
46
+ // The staging area is bounded. One attachment too large for it is refused
47
+ // (the message that names it is rejected and the phone sends it inline);
48
+ // otherwise the oldest waiting ones make room, since a message that never
49
+ // came is the likeliest reason they are still here.
50
+ if (body.length > MAX_STAGED_BYTES) return false;
51
+ makeRoom(dir, body.length, id);
52
+ writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
43
53
  return true;
44
54
  }
45
55
 
56
+ function makeRoom(dir: string, incoming: number, incomingId: string): void {
57
+ let staged: Array<{ path: string; size: number; mtimeMs: number }> = [];
58
+ try {
59
+ staged = readdirSync(dir)
60
+ .filter((name) => name.endsWith(".json") && name !== `${incomingId}.json`)
61
+ .flatMap((name) => {
62
+ try {
63
+ const stat = statSync(join(dir, name));
64
+ return [{ path: join(dir, name), size: stat.size, mtimeMs: stat.mtimeMs }];
65
+ } catch {
66
+ return [];
67
+ }
68
+ })
69
+ .sort((left, right) => left.mtimeMs - right.mtimeMs);
70
+ } catch {
71
+ return;
72
+ }
73
+ let count = staged.length;
74
+ let bytes = staged.reduce((total, item) => total + item.size, 0);
75
+ for (const item of staged) {
76
+ if (count < MAX_STAGED_ATTACHMENTS && bytes + incoming <= MAX_STAGED_BYTES) break;
77
+ rmSync(item.path, { force: true });
78
+ count -= 1;
79
+ bytes -= item.size;
80
+ }
81
+ }
82
+
46
83
  /** The attachment the message named, taken off disk; nothing when it never came. */
47
84
  export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
48
85
  const id = safeId(attachmentId);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What the person may do to the mesh that no agent may.
3
+ *
4
+ * A claim is released by its owner, and only its owner: that is what keeps
5
+ * one agent from clearing another's hold on a file. It also means a claim
6
+ * whose owner died, or will not let go, stays until it expires. The person
7
+ * is not an owner and is not bound by that rule; they are the authority the
8
+ * rule protects. A release from the phone is therefore a command of its
9
+ * own, checked against the Project it names, and written down.
10
+ */
11
+ import type { MeshClaimRelease, ResourceClaim } from "../../../../packages/protocol/schema";
12
+ import type { MeshStore } from "./store";
13
+
14
+ export type PersonRelease =
15
+ | { released: true; claim: ResourceClaim }
16
+ | { released: false; reason: "unknown_claim" | "other_project" };
17
+
18
+ export function releaseClaimByPerson(
19
+ store: MeshStore,
20
+ request: MeshClaimRelease,
21
+ log: (line: string) => void = (line) => process.stderr.write(`[monitor] mesh: ${line}\n`),
22
+ ): PersonRelease {
23
+ // The claim must be one of this Project's: a claim id is not a secret, and
24
+ // a Project's person does not reach into another Project with it.
25
+ const inProject = store.snapshot(request.projectId)?.claims.find((item) => item.claimId === request.claimId);
26
+ if (!inProject) {
27
+ const elsewhere = store.activeClaims().some((item) => item.claimId === request.claimId);
28
+ log(`release of ${request.claimId} refused: ${elsewhere ? "not in this Project" : "no such claim"}`);
29
+ return { released: false, reason: elsewhere ? "other_project" : "unknown_claim" };
30
+ }
31
+ store.releaseClaim(request.claimId);
32
+ log(
33
+ `claim ${request.claimId} on ${inProject.resource} held by ${inProject.ownerSessionId} `
34
+ + `released by the person${request.reason ? `: ${request.reason}` : ""}`,
35
+ );
36
+ return { released: true, claim: inProject };
37
+ }
@@ -87,7 +87,9 @@ export function buildTaskCapsule(
87
87
  };
88
88
  // A checkpoint commit is the fact the capsule carries instead of the dirty
89
89
  // tree: it holds every change the tree had, so relative to it the tree is
90
- // clean. Whether the destination has that commit is its own check.
90
+ // clean. Whether the destination has that commit is its own check. What
91
+ // the commit does not hold is said too, so the destination never takes a
92
+ // partial checkpoint for the whole of the work.
91
93
  const described = checkpoint
92
94
  ? {
93
95
  ...capsule,
@@ -99,13 +101,38 @@ export function buildTaskCapsule(
99
101
  remainingWork: [
100
102
  `Continue from checkpoint ${checkpoint.sha.slice(0, 12)} on ${checkpoint.branch}.`,
101
103
  `Push ${checkpoint.branch} from ${computerId} if the destination does not have it.`,
104
+ ...checkpointRemainingWork(checkpoint, computerId),
102
105
  ],
106
+ importantDecisions: checkpointDecisions(checkpoint),
107
+ checkpoint: {
108
+ status: checkpoint.status,
109
+ files: checkpoint.files,
110
+ excluded: checkpoint.excluded.slice(0, 32),
111
+ },
103
112
  }
104
113
  : capsule;
105
114
  const parsed = TaskCapsule.safeParse(described);
106
115
  return parsed.success ? parsed.data : undefined;
107
116
  }
108
117
 
118
+ function checkpointRemainingWork(checkpoint: Checkpoint, computerId: string): string[] {
119
+ if (checkpoint.excluded.length === 0) return [];
120
+ return [
121
+ `Left on ${computerId}, not in the checkpoint: ${checkpoint.excluded.slice(0, 8).join(", ")}`
122
+ + `${checkpoint.excluded.length > 8 ? ` (+${checkpoint.excluded.length - 8})` : ""}. `
123
+ + "They look like secrets; recreate them there if the work needs them.",
124
+ ].map((line) => line.slice(0, 1_000));
125
+ }
126
+
127
+ function checkpointDecisions(checkpoint: Checkpoint): string[] {
128
+ switch (checkpoint.status) {
129
+ case "complete": return [];
130
+ case "partial": return ["Checkpoint is partial: files that look like secrets stayed on the source computer."];
131
+ case "requires_review":
132
+ return ["Checkpoint needs review: the checkout was shared with other work, so the commit may carry changes that are not this Task's."];
133
+ }
134
+ }
135
+
109
136
  function changedFilesBetween(cwd: string, from: string, to: string): string[] {
110
137
  const listed = git(cwd, ["diff", "--name-only", `${from}..${to}`]) ?? "";
111
138
  return [...new Set(listed.split("\n").map((line) => line.trim()).filter(Boolean))].slice(0, 64);
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { sensitivePath } from "../sessions/edit-stats";
5
+ import { secretFilePath } from "../sessions/edit-stats";
6
6
 
7
7
  /**
8
8
  * Commit everything uncommitted to a checkpoint branch, touching nothing else.
@@ -14,12 +14,21 @@ import { sensitivePath } from "../sessions/edit-stats";
14
14
  * publishing a branch is the person's decision, and the destination says so
15
15
  * if the commit has not reached it yet.
16
16
  */
17
+ export type CheckpointStatus = "complete" | "partial" | "requires_review";
18
+
17
19
  export type Checkpoint = {
18
20
  sha: string;
19
21
  branch: string;
20
22
  files: number;
21
- /** Paths left out because they look like secrets; never part of a checkpoint. */
23
+ /** Paths left out because they are secrets by name; never part of a checkpoint. */
22
24
  excluded: string[];
25
+ /**
26
+ * complete: every uncommitted change is in the commit. partial: secrets
27
+ * stayed behind, named in `excluded`. requires_review: the checkout was
28
+ * shared with other work, so the commit may carry changes that are not
29
+ * this Task's; the person decides.
30
+ */
31
+ status: CheckpointStatus;
23
32
  };
24
33
 
25
34
  export const CHECKPOINT_BRANCH_PREFIX = "granttap/checkpoint/";
@@ -34,13 +43,16 @@ function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env):
34
43
  /**
35
44
  * One branch per checkpoint, not per Task. A Task handed off twice used to
36
45
  * force-move the same branch, and the first checkpoint's commit was left
37
- * unreachable; a stamp on the name keeps each one.
46
+ * unreachable. The name carries the moment to the millisecond: the same
47
+ * request tried again lands on the same branch, two requests never do.
38
48
  */
39
49
  export function checkpointBranchName(taskId: string, at?: number): string {
40
50
  const base = CHECKPOINT_BRANCH_PREFIX + taskId.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 64);
41
51
  if (at == null) return base;
42
- const stamp = new Date(at).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "");
43
- return `${base}-${stamp}`;
52
+ const iso = new Date(at).toISOString();
53
+ const stamp = iso.slice(0, 19).replace(/[-:]/g, "");
54
+ const millis = iso.slice(20, 23);
55
+ return `${base}-${stamp}-${millis}`;
44
56
  }
45
57
 
46
58
  export function createCheckpoint(
@@ -58,9 +70,9 @@ export function createCheckpoint(
58
70
  git(cwd, ["add", "-A"], env);
59
71
  // A checkpoint is the whole checkout's uncommitted work, so an .env or a
60
72
  // key that happened to change in it would be committed and, when the
61
- // person pushes, published. Those stay as HEAD has them.
73
+ // person pushes, published. Those stay as HEAD has them, and are named.
62
74
  const excluded = git(cwd, ["diff", "--cached", "--name-only"], env)
63
- .split("\n").filter(Boolean).filter(sensitivePath);
75
+ .split("\n").filter(Boolean).filter(secretFilePath);
64
76
  if (excluded.length > 0) git(cwd, ["reset", "-q", "--", ...excluded], env);
65
77
  const tree = git(cwd, ["write-tree"], env);
66
78
  if (tree === git(cwd, ["rev-parse", "HEAD^{tree}"])) return undefined; // nothing to keep
@@ -69,7 +81,7 @@ export function createCheckpoint(
69
81
  const branch = checkpointBranchName(taskId, at);
70
82
  git(cwd, ["branch", "-f", branch, sha]);
71
83
  const files = git(cwd, ["diff", "--name-only", `${head}..${sha}`]).split("\n").filter(Boolean).length;
72
- return { sha, branch, files, excluded };
84
+ return { sha, branch, files, excluded, status: excluded.length > 0 ? "partial" : "complete" };
73
85
  } catch {
74
86
  return undefined;
75
87
  } finally {
@@ -111,13 +111,20 @@ export function markRunsDelivered(sessionId: string, at: number, only?: readonly
111
111
  }
112
112
  }
113
113
 
114
+ /** How much of a run one line may carry: fewer files, a shorter outcome, when room is short. */
115
+ export type RunDescriptionLimits = { files?: number; outcome?: number };
116
+
114
117
  /** One line a person or a model can read: what was asked, what happened, what it touched. */
115
- export function describeRun(record: RunRecord): string {
116
- const parts = [`«${record.prompt}» ${record.ok ? "" : "failed: "}${record.outcome}`];
117
- if (record.files.length > 0) {
118
- const shown = record.files.slice(0, 6).join(", ");
119
- const more = record.files.length > 6 ? ` (+${record.files.length - 6})` : "";
118
+ export function describeRun(record: RunRecord, limits: RunDescriptionLimits = {}): string {
119
+ const outcome = limits.outcome == null ? record.outcome : compactText(record.outcome, limits.outcome);
120
+ const parts = [`«${record.prompt}» → ${record.ok ? "" : "failed: "}${outcome}`];
121
+ const files = limits.files ?? 6;
122
+ if (record.files.length > 0 && files > 0) {
123
+ const shown = record.files.slice(0, files).join(", ");
124
+ const more = record.files.length > files ? ` (+${record.files.length - files})` : "";
120
125
  parts.push(`wrote ${shown}${more}`);
126
+ } else if (record.files.length > 0) {
127
+ parts.push(`wrote ${record.files.length} file${record.files.length === 1 ? "" : "s"} (see the transcript)`);
121
128
  }
122
129
  if (record.tools > 0) parts.push(`${record.tools} tool call${record.tools === 1 ? "" : "s"}`);
123
130
  if (record.cutOff) {
@@ -7,17 +7,23 @@
7
7
  * `UserPromptSubmit` hook to add to the prompt — the unread journal first,
8
8
  * then the Mesh brief — so the model coordinates without being told to look.
9
9
  *
10
- * The text is bounded, and the bound is honoured by showing fewer runs, never
11
- * by cutting the text: a run is marked read only once it was actually shown,
12
- * so what did not fit this time comes on the next turn instead of vanishing.
10
+ * The text is bounded, and the bound is honoured by saying less, never by
11
+ * cutting: fewer runs, then fewer Mesh lines, then shorter run lines. The
12
+ * Project's name and the way to the full map are always kept. A run is
13
+ * marked read only once it was actually shown, whole or in its short form;
14
+ * what did not fit comes on the next turn instead of vanishing.
13
15
  */
14
16
  import { liveExecutionScope } from "./capability";
15
- import { describeRun, markRunsDelivered, unreadRuns, type RunRecord } from "./journal";
17
+ import {
18
+ describeRun, markRunsDelivered, unreadRuns, type RunDescriptionLimits, type RunRecord,
19
+ } from "./journal";
16
20
  import { meshBrief } from "./map";
17
21
  import type { MeshSnapshot } from "../../../../packages/protocol/schema";
18
22
 
19
23
  export const MAX_CONTEXT_CHARS = 2_400;
20
24
  const MAX_RUNS_SHOWN = 5;
25
+ /** Ever shorter ways to say one run, tried in order when room is short. */
26
+ const COMPACTIONS: RunDescriptionLimits[] = [{}, { files: 3, outcome: 300 }, { files: 0, outcome: 120 }];
21
27
 
22
28
  export type PromptContextDeps = {
23
29
  unread: (sessionId: string) => RunRecord[];
@@ -39,22 +45,30 @@ function clock(at: number): string {
39
45
  return new Date(at).toISOString().slice(11, 16);
40
46
  }
41
47
 
42
- function runLines(total: number, shown: RunRecord[]): string[] {
48
+ function runLines(total: number, shown: RunRecord[], limits: RunDescriptionLimits): string[] {
43
49
  if (shown.length === 0) return [];
44
50
  const lines = [
45
51
  `GrantTap: ${total} message${total === 1 ? "" : "s"} from the phone ${total === 1 ? "was" : "were"} `
46
52
  + "handled in this chat by background runs since your last turn. Their turns are in the transcript but not in your context:",
47
53
  ];
48
54
  shown.forEach((run, index) => {
49
- lines.push(`${index + 1}. [${clock(run.at)}] ${describeRun(run)}`);
55
+ lines.push(`${index + 1}. [${clock(run.at)}] ${describeRun(run, limits)}`);
50
56
  });
51
57
  if (total > shown.length) lines.push(`(+${total - shown.length} earlier; they follow on your next turn)`);
52
58
  lines.push("Continue from what they did; check the working tree before redoing or undoing it.");
53
59
  return lines;
54
60
  }
55
61
 
56
- function compose(runs: string[], brief: string[]): string {
57
- return [...runs, ...(runs.length > 0 && brief.length > 0 ? [""] : []), ...brief].join("\n");
62
+ /** The Mesh part of the prompt: the Project's name and the map are the envelope, the bullets can go. */
63
+ type Brief = { head: string; bullets: string[]; map: string };
64
+
65
+ function briefLines(brief: Brief | undefined): string[] {
66
+ return brief ? [brief.head, ...brief.bullets, brief.map] : [];
67
+ }
68
+
69
+ function compose(runs: string[], brief: Brief | undefined): string {
70
+ const mesh = briefLines(brief);
71
+ return [...runs, ...(runs.length > 0 && mesh.length > 0 ? [""] : []), ...mesh].join("\n");
58
72
  }
59
73
 
60
74
  /** The text to add to the next prompt of a chat, or nothing when there is nothing new. */
@@ -66,33 +80,44 @@ export function promptContext(
66
80
  if (!sessionId) return undefined;
67
81
  const runs = deps.unread(sessionId);
68
82
  const scope = deps.scope(sessionId);
69
- let brief: string[] = [];
83
+ let brief: Brief | undefined;
70
84
  if (scope) {
71
85
  const lines = meshBrief(scope.snapshot, scope.taskId, now);
72
86
  if (lines.length > 0) {
73
- brief = [
74
- `Project Mesh «${scope.snapshot.project.name}»:`,
75
- ...lines.map((line) => `- ${line}`),
87
+ brief = {
88
+ head: `Project Mesh «${scope.snapshot.project.name}»:`,
89
+ bullets: lines.map((line) => `- ${line}`),
76
90
  // Listed by name, not by token: Claude Code reads only listed resources,
77
91
  // and the server finds the chat from its own environment.
78
- "Full map: read the granttap MCP resource granttap://mesh/map",
79
- ];
92
+ map: "Full map: read the granttap MCP resource granttap://mesh/map",
93
+ };
80
94
  }
81
95
  }
82
- if (runs.length === 0 && brief.length === 0) return undefined;
83
- // The newest runs first, as many as fit; then the brief gives way, one line
84
- // at a time, because it is rebuilt from the Mesh on every turn anyway.
96
+ if (runs.length === 0 && !brief) return undefined;
85
97
  let shown = runs.slice(-MAX_RUNS_SHOWN);
86
- let text = compose(runLines(runs.length, shown), brief);
98
+ let limits = COMPACTIONS[0]!;
99
+ let text = compose(runLines(runs.length, shown, limits), brief);
100
+ // The newest runs first, as many as fit; then the Mesh bullets give way,
101
+ // because they are rebuilt from the Mesh on every turn; then each run is
102
+ // said more briefly. The envelope — the Project and the map — stays.
87
103
  while (text.length > MAX_CONTEXT_CHARS && shown.length > 1) {
88
104
  shown = shown.slice(1);
89
- text = compose(runLines(runs.length, shown), brief);
105
+ text = compose(runLines(runs.length, shown, limits), brief);
106
+ }
107
+ while (text.length > MAX_CONTEXT_CHARS && brief && brief.bullets.length > 0) {
108
+ brief = { ...brief, bullets: brief.bullets.slice(0, -1) };
109
+ text = compose(runLines(runs.length, shown, limits), brief);
90
110
  }
91
- while (text.length > MAX_CONTEXT_CHARS && brief.length > 2) {
92
- brief = [...brief.slice(0, -2), brief[brief.length - 1]!];
93
- text = compose(runLines(runs.length, shown), brief);
111
+ for (const compaction of COMPACTIONS.slice(1)) {
112
+ if (text.length <= MAX_CONTEXT_CHARS) break;
113
+ limits = compaction;
114
+ text = compose(runLines(runs.length, shown, limits), brief);
94
115
  }
95
- if (text.length > MAX_CONTEXT_CHARS) text = `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…`;
96
- if (shown.length > 0) deps.markDelivered(sessionId, now, shown.map((run) => run.at));
116
+ // Bounded by construction; should a line still not fit, it is cut and the
117
+ // run it belongs to is left unread rather than marked as told.
118
+ const cut = text.length > MAX_CONTEXT_CHARS;
119
+ if (cut) text = `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…`;
120
+ const told = cut ? shown.slice(0, -1) : shown;
121
+ if (told.length > 0) deps.markDelivered(sessionId, now, told.map((run) => run.at));
97
122
  return text;
98
123
  }
@@ -20,6 +20,15 @@ export function createHandoffFlow(deps: MeshRuntimeDependencies) {
20
20
  `Base SHA: ${capsule.baseSha}`,
21
21
  ];
22
22
  if (capsule.latestCommit) lines.push(`Latest commit: ${capsule.latestCommit}`);
23
+ if (capsule.checkpoint) {
24
+ const { status, files, excluded } = capsule.checkpoint;
25
+ const line = status === "complete"
26
+ ? `Checkpoint: complete (${files} file${files === 1 ? "" : "s"}).`
27
+ : status === "partial"
28
+ ? `Checkpoint: partial (${files} file${files === 1 ? "" : "s"}); left on the source computer as secrets: ${excluded.join(", ")}.`
29
+ : `Checkpoint: needs review (${files} file${files === 1 ? "" : "s"}); the checkout was shared with other work, so some changes may not be this task's.`;
30
+ lines.push(line);
31
+ }
23
32
  if (capsule.testsStatus) lines.push(`Tests: ${capsule.testsStatus}`);
24
33
  if (capsule.importantDecisions.length) {
25
34
  lines.push(`Important decisions:\n- ${capsule.importantDecisions.join("\n- ")}`);
@@ -110,10 +110,21 @@ export function createMeshRuntime(deps: MeshRuntimeDependencies) {
110
110
  }
111
111
  // Asked to checkpoint, uncommitted work is committed to a branch of its
112
112
  // own first, so the Task can leave without losing it. Nothing is pushed.
113
+ // The branch is named by the request's own moment, so the same request
114
+ // tried again lands on the same branch and two requests never do.
113
115
  const cwd = session.worktree ?? session.cwd;
114
- const checkpoint = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
115
- ? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, deps.now())
116
+ const made = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
117
+ ? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, request.createdAt)
116
118
  : undefined;
119
+ // A checkout other Tasks are working in at the same time holds their
120
+ // changes too; the commit cannot tell them apart, so it says so.
121
+ const shared = made && cwd
122
+ ? sessions.some((other) =>
123
+ other.sessionId !== session.sessionId
124
+ && other.taskId != null && other.taskId !== request.taskId
125
+ && (other.worktree ?? other.cwd) === cwd)
126
+ : false;
127
+ const checkpoint = made && shared ? { ...made, status: "requires_review" as const } : made;
117
128
  const capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
118
129
  const readiness = handoffReadiness({
119
130
  capsule,
@@ -20,9 +20,25 @@ import {
20
20
  type TaskDependency as DependencyValue,
21
21
  } from "../../../../packages/protocol/schema";
22
22
  import { lstatSync, readFileSync } from "node:fs";
23
+ import { z } from "zod";
24
+ import { capsuleHash } from "./handoff";
23
25
 
24
26
  const MAX_STORE_BYTES = 4 * 1_024 * 1_024;
25
27
 
28
+ /**
29
+ * A capsule rewritten when two Tasks were rejoined, on record: its hash
30
+ * changed with its Task id, and a receipt that names the old hash is still a
31
+ * receipt for it. Kept locally only; nothing on the wire carries it.
32
+ */
33
+ export const CapsuleMigration = z.object({
34
+ at: z.number().nonnegative(),
35
+ taskIdFrom: z.string().min(1).max(128),
36
+ taskIdTo: z.string().min(1).max(128),
37
+ capsuleHashFrom: z.string().regex(/^[0-9a-f]{64}$/),
38
+ capsuleHashTo: z.string().regex(/^[0-9a-f]{64}$/),
39
+ }).strict();
40
+ export type CapsuleMigration = z.infer<typeof CapsuleMigration>;
41
+
26
42
  export type StoreState = {
27
43
  version: 1;
28
44
  projects: ProjectValue[];
@@ -34,14 +50,16 @@ export type StoreState = {
34
50
  dependencies: DependencyValue[];
35
51
  events: MeshEventValue[];
36
52
  receipts: ReceiptValue[];
53
+ migrations: CapsuleMigration[];
37
54
  };
38
55
 
39
56
  const EMPTY: StoreState = {
40
57
  version: 1, projects: [], bindings: [], peers: [], tasks: [], executions: [], claims: [],
41
- dependencies: [], events: [], receipts: [],
58
+ dependencies: [], events: [], receipts: [], migrations: [],
42
59
  };
43
60
 
44
61
  export const MAX_STORE_PEERS = 256;
62
+ export const MAX_STORE_MIGRATIONS = 64;
45
63
 
46
64
  function parsedArray<T>(value: unknown, schema: { safeParse: (input: unknown) => { success: boolean; data?: T } }): T[] {
47
65
  if (!Array.isArray(value)) return [];
@@ -98,6 +116,7 @@ export function readStoreState(path: string): StoreLoad {
98
116
  dependencies: parsedArray(value.dependencies, TaskDependency),
99
117
  events: parsedArray(value.events, MeshEvent),
100
118
  receipts: parsedArray(value.receipts, HandoffReceipt),
119
+ migrations: parsedArray(value.migrations, CapsuleMigration).slice(-MAX_STORE_MIGRATIONS),
101
120
  }),
102
121
  };
103
122
  } catch {
@@ -172,15 +191,39 @@ function collapseSplitChats(state: StoreState): StoreState {
172
191
  if (next.taskId === next.dependsOnTaskId) continue;
173
192
  dependencies.set(`${next.taskId}\0${next.dependsOnTaskId}`, next);
174
193
  }
194
+ // A capsule carries its Task id and is named by its hash; moving the id
195
+ // changes the hash, so every receipt that named the old hash names the new
196
+ // one, and the move itself is written down for a receipt still on its way.
197
+ const now = Date.now();
198
+ const migrations = new Map(state.migrations.map((item) => [item.capsuleHashFrom, item]));
199
+ const rehashed = new Map<string, string>();
200
+ const events = state.events.map((event) => {
201
+ const next = rescopedEvent(event, target);
202
+ const before = event.payload.capsule;
203
+ const after = next.payload.capsule;
204
+ if (before && after && event.taskId !== next.taskId) {
205
+ const from = capsuleHash(before);
206
+ const to = capsuleHash(after);
207
+ rehashed.set(from, to);
208
+ migrations.set(from, {
209
+ at: now, taskIdFrom: event.taskId, taskIdTo: next.taskId, capsuleHashFrom: from, capsuleHashTo: to,
210
+ });
211
+ }
212
+ return next;
213
+ });
214
+ const rehash = (hash: string): string => rehashed.get(hash) ?? hash;
175
215
  return {
176
216
  ...state,
177
217
  tasks: state.tasks.filter((task) => target(task.taskId) === task.taskId),
178
218
  executions: state.executions.map(scoped),
179
219
  claims: state.claims.map(scoped),
180
220
  dependencies: [...dependencies.values()],
181
- events: state.events.map((item) => rescopedEvent(item, target)),
182
- // A receipt decides who owns a chat, so it must name the surviving Task.
183
- receipts: state.receipts.map(scoped),
221
+ events: events.map((event) => event.payload.receipt
222
+ ? { ...event, payload: { ...event.payload, receipt: { ...event.payload.receipt, capsuleHash: rehash(event.payload.receipt.capsuleHash) } } }
223
+ : event),
224
+ // A receipt decides who owns a chat, so it must name the surviving Task and its capsule.
225
+ receipts: state.receipts.map((item) => ({ ...scoped(item), capsuleHash: rehash(item.capsuleHash) })),
226
+ migrations: [...migrations.values()].slice(-MAX_STORE_MIGRATIONS),
184
227
  };
185
228
  }
186
229