granttap-mcp 0.8.4 → 0.8.5
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/apps/bridge/src/mesh/capsule.ts +28 -1
- package/apps/bridge/src/mesh/checkpoint.ts +20 -8
- package/apps/bridge/src/mesh/journal.ts +12 -5
- package/apps/bridge/src/mesh/prompt-context.ts +49 -24
- package/apps/bridge/src/mesh/runtime-handoff.ts +9 -0
- package/apps/bridge/src/mesh/runtime.ts +13 -2
- package/apps/bridge/src/mesh/store-state.ts +47 -4
- package/apps/bridge/src/mesh/store-sync.ts +115 -40
- package/apps/bridge/src/mesh/store.ts +71 -22
- package/apps/bridge/src/project-policy/runtime.ts +5 -0
- package/apps/bridge/src/sessions/edit-stats.ts +23 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh.ts +11 -0
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
43
|
-
|
|
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(
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
115
|
-
? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId,
|
|
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:
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
* the last writer replaced whatever the other had written since: a Task
|
|
7
7
|
* linked by one process vanished when the other saved a claim. A store now
|
|
8
8
|
* reloads the file whenever it has changed under it, and saves by merging
|
|
9
|
-
* what it changed since the last sync into what is on disk, under a
|
|
10
|
-
*
|
|
9
|
+
* what it changed since the last sync into what is on disk, under a lock
|
|
10
|
+
* beside the file that is held by a named, living process.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import {
|
|
14
|
+
chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
14
17
|
import type {
|
|
15
18
|
ExecutionSessionLink as ExecutionValue,
|
|
16
19
|
IntegrationPeer as IntegrationPeerValue,
|
|
@@ -18,16 +21,18 @@ import type {
|
|
|
18
21
|
} from "../../../../packages/protocol/schema";
|
|
19
22
|
import { preferExecution, preferTask } from "./convergence";
|
|
20
23
|
import { integrationPeerKey } from "./other-side";
|
|
21
|
-
import { MAX_STORE_PEERS, type StoreState } from "./store-state";
|
|
24
|
+
import { MAX_STORE_MIGRATIONS, MAX_STORE_PEERS, type StoreState } from "./store-state";
|
|
22
25
|
|
|
23
26
|
type Collection = Exclude<keyof StoreState, "version">;
|
|
24
27
|
|
|
25
28
|
const COLLECTIONS: Collection[] = [
|
|
26
|
-
"projects", "bindings", "peers", "tasks", "executions", "claims", "dependencies", "events",
|
|
29
|
+
"projects", "bindings", "peers", "tasks", "executions", "claims", "dependencies", "events",
|
|
30
|
+
"receipts", "migrations",
|
|
27
31
|
];
|
|
28
|
-
const BOUNDS: Partial<Record<Collection, number>> = {
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const BOUNDS: Partial<Record<Collection, number>> = {
|
|
33
|
+
peers: MAX_STORE_PEERS, events: 512, receipts: 256, migrations: MAX_STORE_MIGRATIONS,
|
|
34
|
+
};
|
|
35
|
+
export const LOCK_WAIT_MS = 8_000;
|
|
31
36
|
const LOCK_POLL_MS = 5;
|
|
32
37
|
|
|
33
38
|
function rowKey(name: Collection, item: unknown): string {
|
|
@@ -42,27 +47,34 @@ function rowKey(name: Collection, item: unknown): string {
|
|
|
42
47
|
case "dependencies": return [row.taskId, row.dependsOnTaskId].join("\0");
|
|
43
48
|
case "events": return row.eventId ?? "";
|
|
44
49
|
case "receipts": return row.capsuleHash ?? "";
|
|
50
|
+
case "migrations": return row.capsuleHashFrom ?? "";
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
type Upsert = { item: unknown; before?: string };
|
|
49
|
-
|
|
55
|
+
/** A removal names the row as it was read, so a row changed meanwhile is kept. */
|
|
56
|
+
type Removal = { key: string; before: string };
|
|
57
|
+
export type StoreDelta = Record<Collection, { upserts: Upsert[]; removed: Removal[] }>;
|
|
50
58
|
|
|
51
59
|
/** What this process changed since it last agreed with the disk. */
|
|
52
60
|
export function storeDelta(baseline: StoreState, current: StoreState): StoreDelta {
|
|
53
61
|
const delta = {} as StoreDelta;
|
|
54
62
|
for (const name of COLLECTIONS) {
|
|
55
63
|
const before = new Map<string, string>();
|
|
56
|
-
for (const item of baseline[name]) before.set(rowKey(name, item), JSON.stringify(item));
|
|
64
|
+
for (const item of baseline[name] ?? []) before.set(rowKey(name, item), JSON.stringify(item));
|
|
57
65
|
const after = new Set<string>();
|
|
58
66
|
const upserts: Upsert[] = [];
|
|
59
|
-
for (const item of current[name]) {
|
|
67
|
+
for (const item of current[name] ?? []) {
|
|
60
68
|
const key = rowKey(name, item);
|
|
61
69
|
after.add(key);
|
|
62
70
|
const previous = before.get(key);
|
|
63
71
|
if (previous !== JSON.stringify(item)) upserts.push({ item, before: previous });
|
|
64
72
|
}
|
|
65
|
-
|
|
73
|
+
const removed: Removal[] = [];
|
|
74
|
+
for (const [key, previous] of before) {
|
|
75
|
+
if (!after.has(key)) removed.push({ key, before: previous });
|
|
76
|
+
}
|
|
77
|
+
delta[name] = { upserts, removed };
|
|
66
78
|
}
|
|
67
79
|
return delta;
|
|
68
80
|
}
|
|
@@ -75,18 +87,22 @@ export function deltaIsEmpty(delta: StoreDelta): boolean {
|
|
|
75
87
|
/**
|
|
76
88
|
* This process's changes laid over what another process wrote meanwhile. A
|
|
77
89
|
* row only we changed is ours; a row both changed is settled the way two
|
|
78
|
-
* computers settle it, so every process converges on the same file.
|
|
90
|
+
* computers settle it, so every process converges on the same file. A row
|
|
91
|
+
* we removed is removed only as we read it: a claim renewed by another
|
|
92
|
+
* process since is not the claim we decided had expired.
|
|
79
93
|
*/
|
|
80
94
|
export function applyStoreDelta(disk: StoreState, delta: StoreDelta): StoreState {
|
|
81
95
|
const merged: Record<string, unknown> = { ...disk };
|
|
82
96
|
for (const name of COLLECTIONS) {
|
|
83
97
|
const { upserts, removed } = delta[name];
|
|
84
98
|
if (upserts.length === 0 && removed.length === 0) continue;
|
|
85
|
-
const gone = new
|
|
99
|
+
const gone = new Map(removed.map((removal) => [removal.key, removal.before]));
|
|
86
100
|
const rows = new Map<string, unknown>();
|
|
87
|
-
for (const item of disk[name]) {
|
|
101
|
+
for (const item of disk[name] ?? []) {
|
|
88
102
|
const key = rowKey(name, item);
|
|
89
|
-
|
|
103
|
+
const asRead = gone.get(key);
|
|
104
|
+
if (asRead != null && asRead === JSON.stringify(item)) continue;
|
|
105
|
+
rows.set(key, item);
|
|
90
106
|
}
|
|
91
107
|
for (const { item, before } of upserts) {
|
|
92
108
|
const key = rowKey(name, item);
|
|
@@ -131,7 +147,7 @@ export function writeStoreState(path: string, state: StoreState): void {
|
|
|
131
147
|
export function setAsideStore(path: string, now = Date.now()): boolean {
|
|
132
148
|
try {
|
|
133
149
|
let target = `${path}.unreadable-${now}`;
|
|
134
|
-
for (let attempt = 2;
|
|
150
|
+
for (let attempt = 2; exists(target) && attempt < 100; attempt += 1) {
|
|
135
151
|
target = `${path}.unreadable-${now}-${attempt}`;
|
|
136
152
|
}
|
|
137
153
|
renameSync(path, target);
|
|
@@ -141,49 +157,108 @@ export function setAsideStore(path: string, now = Date.now()): boolean {
|
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
159
|
|
|
160
|
+
function exists(path: string): boolean {
|
|
161
|
+
try {
|
|
162
|
+
statSync(path);
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
144
169
|
function pause(ms: number): void {
|
|
145
170
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
146
171
|
}
|
|
147
172
|
|
|
173
|
+
/** The lock could not be taken in time. The write is kept in memory and tried again later. */
|
|
174
|
+
export class StoreLockError extends Error {}
|
|
175
|
+
|
|
176
|
+
type LockOwner = { pid: number; token: string };
|
|
177
|
+
|
|
178
|
+
function lockOwner(lock: string): LockOwner | undefined {
|
|
179
|
+
try {
|
|
180
|
+
const [pid, token] = readFileSync(join(lock, "owner"), "utf8").trim().split(":");
|
|
181
|
+
const parsed = Number(pid);
|
|
182
|
+
return Number.isInteger(parsed) && parsed > 0 && token ? { pid: parsed, token } : undefined;
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function processAlive(pid: number): boolean {
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Take a lock whose owner is gone: moved aside first, so two waiters cannot both take it. */
|
|
198
|
+
function takeOverStale(lock: string): void {
|
|
199
|
+
const aside = `${lock}.stale-${process.pid}-${Date.now()}`;
|
|
200
|
+
try {
|
|
201
|
+
renameSync(lock, aside);
|
|
202
|
+
} catch {
|
|
203
|
+
return; // Someone else took it over first.
|
|
204
|
+
}
|
|
205
|
+
rmSync(aside, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
|
|
148
208
|
/**
|
|
149
209
|
* Run under the store's lock: a directory beside the file, the one thing a
|
|
150
|
-
* file system creates atomically
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
210
|
+
* file system creates atomically, naming the process that holds it.
|
|
211
|
+
*
|
|
212
|
+
* A lock whose owner is no longer running is taken over at once; a lock whose
|
|
213
|
+
* owner is alive is waited for, and when the wait runs out the caller gets a
|
|
214
|
+
* StoreLockError instead of the critical section. A lock is released only by
|
|
215
|
+
* the process and token that took it, so an owner that finishes late never
|
|
216
|
+
* removes a lock taken over meanwhile.
|
|
154
217
|
*/
|
|
155
|
-
export function withStoreLock<T>(path: string, run: () => T): T {
|
|
218
|
+
export function withStoreLock<T>(path: string, run: () => T, options: { waitMs?: number } = {}): T {
|
|
156
219
|
mkdirSync(dirname(path), { recursive: true });
|
|
157
220
|
const lock = `${path}.lock`;
|
|
221
|
+
const token = randomBytes(8).toString("hex");
|
|
222
|
+
const waitMs = options.waitMs ?? LOCK_WAIT_MS;
|
|
158
223
|
const started = Date.now();
|
|
159
|
-
|
|
160
|
-
while (!held) {
|
|
224
|
+
for (;;) {
|
|
161
225
|
try {
|
|
162
226
|
mkdirSync(lock);
|
|
163
|
-
|
|
227
|
+
writeFileSync(join(lock, "owner"), `${process.pid}:${token}`, { mode: 0o600 });
|
|
228
|
+
break;
|
|
164
229
|
} catch (error) {
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
172
|
-
} catch {
|
|
230
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
231
|
+
if (code !== "EEXIST") throw new StoreLockError(`the store lock could not be taken (${code ?? String(error)})`);
|
|
232
|
+
const owner = lockOwner(lock);
|
|
233
|
+
if (owner && !processAlive(owner.pid)) {
|
|
234
|
+
takeOverStale(lock);
|
|
173
235
|
continue;
|
|
174
236
|
}
|
|
237
|
+
if (!owner && lockAge(lock) > waitMs) {
|
|
238
|
+
// A lock without an owner file for this long was never finished being taken.
|
|
239
|
+
takeOverStale(lock);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (Date.now() - started > waitMs) {
|
|
243
|
+
throw new StoreLockError("the store lock is held by another running process");
|
|
244
|
+
}
|
|
175
245
|
pause(LOCK_POLL_MS);
|
|
176
246
|
}
|
|
177
247
|
}
|
|
178
248
|
try {
|
|
179
249
|
return run();
|
|
180
250
|
} finally {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
} catch {
|
|
185
|
-
// Taken over as stale by a process that waited longer than we ran.
|
|
186
|
-
}
|
|
251
|
+
const owner = lockOwner(lock);
|
|
252
|
+
if (owner && owner.pid === process.pid && owner.token === token) {
|
|
253
|
+
rmSync(lock, { recursive: true, force: true });
|
|
187
254
|
}
|
|
188
255
|
}
|
|
189
256
|
}
|
|
257
|
+
|
|
258
|
+
function lockAge(lock: string): number {
|
|
259
|
+
try {
|
|
260
|
+
return Date.now() - statSync(lock).mtimeMs;
|
|
261
|
+
} catch {
|
|
262
|
+
return 0;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
setAsideStore,
|
|
26
26
|
storeDelta,
|
|
27
27
|
storeFingerprint,
|
|
28
|
+
StoreLockError,
|
|
28
29
|
withStoreLock,
|
|
29
30
|
writeStoreState,
|
|
30
31
|
} from "./store-sync";
|
|
@@ -48,7 +49,15 @@ export class MeshStore {
|
|
|
48
49
|
/** The file this process last read or wrote; another file under the path is news. */
|
|
49
50
|
private synced: string | undefined;
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
/** How long a write waits for the store's lock before it is kept for later. */
|
|
53
|
+
private readonly lockWaitMs: number | undefined;
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
private readonly path: string,
|
|
57
|
+
private readonly now = Date.now,
|
|
58
|
+
options: { lockWaitMs?: number } = {},
|
|
59
|
+
) {
|
|
60
|
+
this.lockWaitMs = options.lockWaitMs;
|
|
52
61
|
const loaded = readStoreState(path);
|
|
53
62
|
this.state = loaded.state;
|
|
54
63
|
this.baseline = structuredClone(loaded.state);
|
|
@@ -62,14 +71,16 @@ export class MeshStore {
|
|
|
62
71
|
/**
|
|
63
72
|
* Take in what another process wrote since this one last looked. Called on
|
|
64
73
|
* entry to every public method, never in the middle of one, so a change
|
|
65
|
-
* made and not yet saved is never thrown away
|
|
74
|
+
* made and not yet saved is never thrown away: what this process changed
|
|
75
|
+
* and could not yet write is laid over what arrived.
|
|
66
76
|
*/
|
|
67
77
|
private sync(): void {
|
|
68
78
|
const current = storeFingerprint(this.path);
|
|
69
79
|
if (current === this.synced) return;
|
|
70
80
|
const loaded = readStoreState(this.path);
|
|
71
81
|
if (loaded.status === "ok" || loaded.status === "missing") {
|
|
72
|
-
|
|
82
|
+
const pending = storeDelta(this.baseline, this.state);
|
|
83
|
+
this.state = deltaIsEmpty(pending) ? loaded.state : applyStoreDelta(loaded.state, pending);
|
|
73
84
|
this.baseline = structuredClone(loaded.state);
|
|
74
85
|
this.synced = current;
|
|
75
86
|
return;
|
|
@@ -78,24 +89,55 @@ export class MeshStore {
|
|
|
78
89
|
this.synced = storeFingerprint(this.path);
|
|
79
90
|
}
|
|
80
91
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Write what changed here over what is on disk now, under the store's lock.
|
|
94
|
+
* A lock that cannot be taken in time is not a reason to write anyway: the
|
|
95
|
+
* change stays in memory, ahead of the baseline, and the next save carries it.
|
|
96
|
+
*/
|
|
97
|
+
private save(): boolean {
|
|
98
|
+
try {
|
|
99
|
+
withStoreLock(this.path, () => {
|
|
100
|
+
const delta = storeDelta(this.baseline, this.state);
|
|
101
|
+
let merged = this.state;
|
|
102
|
+
if (storeFingerprint(this.path) !== this.synced) {
|
|
103
|
+
const disk = readStoreState(this.path);
|
|
104
|
+
if (disk.status === "ok") {
|
|
105
|
+
merged = deltaIsEmpty(delta) ? disk.state : applyStoreDelta(disk.state, delta);
|
|
106
|
+
} else if (disk.status === "corrupt" || disk.status === "too_large") {
|
|
107
|
+
setAsideStore(this.path, this.now());
|
|
108
|
+
}
|
|
92
109
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
writeStoreState(this.path, merged);
|
|
111
|
+
this.state = merged;
|
|
112
|
+
this.baseline = structuredClone(merged);
|
|
113
|
+
this.synced = storeFingerprint(this.path);
|
|
114
|
+
}, { waitMs: this.lockWaitMs });
|
|
115
|
+
return true;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!(error instanceof StoreLockError)) throw error;
|
|
118
|
+
this.unsaved = true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether the last write was held back by the lock; cleared by the next successful write. */
|
|
124
|
+
private unsaved = false;
|
|
125
|
+
|
|
126
|
+
/** Whether a change made here is still waiting for the lock to be written. */
|
|
127
|
+
get hasUnsavedChanges(): boolean {
|
|
128
|
+
return this.unsaved && !deltaIsEmpty(storeDelta(this.baseline, this.state));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Try again to write what the lock held back. */
|
|
132
|
+
flush(): boolean {
|
|
133
|
+
this.sync();
|
|
134
|
+
if (deltaIsEmpty(storeDelta(this.baseline, this.state))) {
|
|
135
|
+
this.unsaved = false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
const written = this.save();
|
|
139
|
+
if (written) this.unsaved = false;
|
|
140
|
+
return written;
|
|
99
141
|
}
|
|
100
142
|
|
|
101
143
|
upsertProject(input: ProjectValue): void {
|
|
@@ -396,8 +438,15 @@ export class MeshStore {
|
|
|
396
438
|
&& item.taskId === event.taskId
|
|
397
439
|
&& item.sourceSessionId === receipt.sourceSessionId
|
|
398
440
|
&& item.payload.capsule != null);
|
|
399
|
-
|
|
400
|
-
|
|
441
|
+
if (request?.payload.capsule == null) return false;
|
|
442
|
+
const digest = capsuleHash(request.payload.capsule);
|
|
443
|
+
if (digest === receipt.capsuleHash) return true;
|
|
444
|
+
// A receipt written before this Task was rejoined names the capsule by
|
|
445
|
+
// the hash it had then; the move is on record, so the receipt still holds.
|
|
446
|
+
return this.state.migrations.some((item) =>
|
|
447
|
+
item.taskIdTo === event.taskId
|
|
448
|
+
&& item.capsuleHashTo === digest
|
|
449
|
+
&& item.capsuleHashFrom === receipt.capsuleHash);
|
|
401
450
|
}
|
|
402
451
|
|
|
403
452
|
private applyEvent(event: MeshEventValue): void {
|
|
@@ -61,6 +61,11 @@ export function rejectionReason(error: unknown): ProjectPolicyRejectionReason {
|
|
|
61
61
|
|
|
62
62
|
export function createProjectPolicyRuntime(deps: ProjectPolicyRuntimeDependencies) {
|
|
63
63
|
async function apply(relay: RelayClient, request: ProjectPolicySet): Promise<boolean> {
|
|
64
|
+
// The phone's edit is itself the enrollment: from this moment the Project
|
|
65
|
+
// is governed on this computer, whether or not the engine can take the
|
|
66
|
+
// policy right now, so a hook that finds the engine silent asks the
|
|
67
|
+
// person instead of falling open.
|
|
68
|
+
rememberGovernedProject(request.projectId, request.policy.revision, deps.now());
|
|
64
69
|
try {
|
|
65
70
|
const applied = await deps.client.request({
|
|
66
71
|
operation: "policy.apply",
|
|
@@ -112,6 +112,29 @@ export function sensitivePath(path: unknown): boolean {
|
|
|
112
112
|
return typeof path === "string" && SENSITIVE_PATH.test(path.trim());
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A file that is a secret by its name alone: an env file, a key, a
|
|
117
|
+
* credentials store. Narrower than `sensitivePath`, on purpose. Hiding a
|
|
118
|
+
* preview of `tokenizer.ts` costs a glance; leaving it out of a checkpoint
|
|
119
|
+
* loses the code, so a checkpoint excludes only what is a secret, not what
|
|
120
|
+
* merely sounds like one.
|
|
121
|
+
*/
|
|
122
|
+
const SECRET_FILE = new RegExp(
|
|
123
|
+
"(^|[\\/])("
|
|
124
|
+
+ "\\.env([.-][^\\/]*)?|[^\\/]+\\.env"
|
|
125
|
+
+ "|[^\\/]*\\.(pem|key|p12|pfx|jks|keystore|der|gpg|asc|kdbx|ovpn|tfstate|tfstate\\.backup)"
|
|
126
|
+
+ "|id_(rsa|ed25519|ecdsa|dsa)(\\.pub)?"
|
|
127
|
+
+ "|credentials(\\.json|\\.ya?ml)?|secrets?(\\.json|\\.ya?ml|\\.toml)|[^\\/]*\\.secrets?"
|
|
128
|
+
+ "|service[-_]account[^\\/]*\\.json"
|
|
129
|
+
+ "|\\.npmrc|\\.netrc|\\.pypirc|\\.htpasswd|\\.git-credentials|\\.docker[\\/]config\\.json"
|
|
130
|
+
+ ")$",
|
|
131
|
+
"i",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
export function secretFilePath(path: unknown): boolean {
|
|
135
|
+
return typeof path === "string" && SECRET_FILE.test(path.trim());
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
/** Bound the lines and their length, and say how many were left out. */
|
|
116
139
|
function boundPreview(lines: string[], redact: (line: string) => string): string | undefined {
|
|
117
140
|
const kept: string[] = [];
|
package/package.json
CHANGED
|
@@ -131,6 +131,17 @@ export const TaskCapsule = z.object({
|
|
|
131
131
|
resourceClaims: z.array(Path).max(64),
|
|
132
132
|
remainingWork: z.array(Detail).max(32),
|
|
133
133
|
importantDecisions: z.array(Detail).max(16),
|
|
134
|
+
/**
|
|
135
|
+
* What the checkpoint commit holds, when the capsule rides on one:
|
|
136
|
+
* complete, partial (secrets stayed on the source computer, named in
|
|
137
|
+
* `excluded`), or requires_review (the checkout was shared with other
|
|
138
|
+
* work, so the commit may carry changes that are not this Task's).
|
|
139
|
+
*/
|
|
140
|
+
checkpoint: z.object({
|
|
141
|
+
status: z.enum(["complete", "partial", "requires_review"]),
|
|
142
|
+
files: z.number().int().nonnegative(),
|
|
143
|
+
excluded: z.array(Path).max(32),
|
|
144
|
+
}).strict().optional(),
|
|
134
145
|
createdAt: z.number().nonnegative(),
|
|
135
146
|
}).strict().superRefine((value, ctx) => {
|
|
136
147
|
if ((value.sourceProvider === "grok_bot") !== (value.sourceActorId != null)) {
|