@staix/agent-mem 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -1
- package/package.json +4 -2
- package/src/cli.ts +97 -6
- package/src/commands-table.ts +16 -0
- package/src/index.ts +5 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/candidates.ts +196 -0
- package/src/memory/commands.ts +9 -2
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/mutation.ts +22 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +235 -36
- package/src/memory/sync.ts +31 -3
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { canonicalJSON, hashJSON, validGeneration } from "./generation.ts";
|
|
2
|
+
import { entryId, type Entry, type NoteEntry } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
export const PRUNE_ROW_MAX_BYTES = 16_384, PRUNE_PLAN_MAX_BYTES = 262_144;
|
|
5
|
+
export const PRUNE_ROW_MAX_TARGETS = 32, PRUNE_PLAN_MAX_ROWS = 16, PRUNE_PLAN_MAX_TARGETS = 256;
|
|
6
|
+
export type PrunePlacement = "local" | "synced";
|
|
7
|
+
export interface PruneTarget {
|
|
8
|
+
id: string; rowHash: string; expiredAt: string;
|
|
9
|
+
basis: { kind: "note" | "demote" | "settle"; id: string; rowHash: string };
|
|
10
|
+
}
|
|
11
|
+
export interface PruneRow {
|
|
12
|
+
schema: "agent-mem.prune/v1"; op: "prune"; generation: string; at: string; machine: string; actor: "person";
|
|
13
|
+
placement: PrunePlacement; cutoff: string; targets: PruneTarget[]; id: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PrunePlan {
|
|
16
|
+
schema: "agent-mem.prune-plan/v1"; generation: string; selectorRevision: string; project: string; policyRevision: string;
|
|
17
|
+
rows: { placement: PrunePlacement; payload: PruneRow }[];
|
|
18
|
+
counts: { eligible: number; selected: number; local: number; synced: number }; hasMore: boolean; seal: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PruneSourceRow { row: Entry; placement: PrunePlacement; publicWitness: boolean; own?: boolean; legacy?: boolean }
|
|
21
|
+
export interface PruneExpiry { targetId: string; basisId: string; expiredAt: string }
|
|
22
|
+
export interface PruneSnapshot {
|
|
23
|
+
rows: readonly PruneSourceRow[];
|
|
24
|
+
/** Effective, explicit serialized deadlines only; implicit TTL/backstop expiry is never eligible. */
|
|
25
|
+
expiries: readonly PruneExpiry[];
|
|
26
|
+
pruned?: readonly string[];
|
|
27
|
+
durablePruneIds?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export interface PruneStorePort {
|
|
30
|
+
generation: string; machine: string;
|
|
31
|
+
selectorRevision(): string;
|
|
32
|
+
now(): number;
|
|
33
|
+
snapshot(): PruneSnapshot;
|
|
34
|
+
withMutation<T>(work: () => T): T;
|
|
35
|
+
/** Must persist/fsync exactly this local plan before append, refusing mismatched existing journals. */
|
|
36
|
+
persistPrunePlan(plan: PrunePlan): void;
|
|
37
|
+
/** Must fsync complete bytes; resume only an exact persisted-plan prefix, never rewrite a complete line. */
|
|
38
|
+
appendPrune(row: PruneRow, line: string, planSeal: string): "written" | "already-durable";
|
|
39
|
+
refresh(): void;
|
|
40
|
+
}
|
|
41
|
+
export interface PrunePolicyBinding { generation: string; project: string; payloadHash: string; policyRevision: string }
|
|
42
|
+
export interface PruneAuthorization {
|
|
43
|
+
project: string; actor: "person" | "agent";
|
|
44
|
+
/** The host authenticates intent. Neither this boolean nor actor is an authentication token. */
|
|
45
|
+
authorize: () => boolean;
|
|
46
|
+
policy?: {
|
|
47
|
+
revision: () => string;
|
|
48
|
+
classify: (row: PruneSourceRow) => "public" | "private" | "unknown";
|
|
49
|
+
attest: (binding: PrunePolicyBinding) => boolean;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const bytes = (value: unknown) => Buffer.byteLength(canonicalJSON(value), "utf8");
|
|
53
|
+
const fullId = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{32}$/.test(value);
|
|
54
|
+
const sha = (value: unknown): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
55
|
+
const revision = (value: unknown): value is string => value === "absent" || sha(value);
|
|
56
|
+
const utc = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
57
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
58
|
+
const exact = (value: Record<string, unknown>, keys: readonly string[]) => Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
59
|
+
const fail = (): never => { throw new Error("prune unavailable: invalid, unavailable or withheld request"); };
|
|
60
|
+
function guard(condition: unknown): asserts condition { if (!condition) fail(); }
|
|
61
|
+
/** Full canonical authored row INCLUDING id, unlike the v1 entry-id hash which excludes id. */
|
|
62
|
+
export const pruneRowHash = (row: Entry): string => hashJSON(row);
|
|
63
|
+
export const prunePayloadHash = (row: PruneRow): string => hashJSON(row);
|
|
64
|
+
export function pruneId(row: Omit<PruneRow, "id">): string { return hashJSON(row).slice(0, 32); }
|
|
65
|
+
function protectedCall<T>(work: () => T): T { try { return work(); } catch { return fail(); } }
|
|
66
|
+
|
|
67
|
+
export function validatePruneRow(input: unknown, generation?: string, placement?: PrunePlacement): PruneRow {
|
|
68
|
+
return protectedCall(() => {
|
|
69
|
+
guard(object(input) && bytes(input) <= PRUNE_ROW_MAX_BYTES);
|
|
70
|
+
const row = input as Record<string, unknown>;
|
|
71
|
+
guard(exact(row, ["schema", "op", "generation", "at", "machine", "actor", "placement", "cutoff", "targets", "id"]));
|
|
72
|
+
guard(row.schema === "agent-mem.prune/v1" && row.op === "prune" && validGeneration(row.generation) && utc(row.at) && utc(row.cutoff));
|
|
73
|
+
guard(typeof row.machine === "string" && /^[a-f0-9]{16}$/.test(row.machine) && row.actor === "person" && ["local", "synced"].includes(String(row.placement)));
|
|
74
|
+
guard((generation === undefined || row.generation === generation) && (placement === undefined || row.placement === placement));
|
|
75
|
+
guard(String(row.cutoff) <= String(row.at));
|
|
76
|
+
guard(Array.isArray(row.targets) && row.targets.length >= 1 && row.targets.length <= PRUNE_ROW_MAX_TARGETS);
|
|
77
|
+
let previous = "";
|
|
78
|
+
for (const value of row.targets as unknown[]) {
|
|
79
|
+
guard(object(value) && exact(value, ["id", "rowHash", "expiredAt", "basis"]));
|
|
80
|
+
const target = value as Record<string, unknown>, basis = target.basis;
|
|
81
|
+
guard(fullId(target.id) && sha(target.rowHash) && utc(target.expiredAt) && target.id > previous && String(target.expiredAt) <= String(row.cutoff));
|
|
82
|
+
guard(object(basis) && exact(basis, ["kind", "id", "rowHash"]) && ["note", "demote", "settle"].includes(String(basis.kind)) && fullId(basis.id) && sha(basis.rowHash));
|
|
83
|
+
previous = target.id as string;
|
|
84
|
+
}
|
|
85
|
+
guard(fullId(row.id));
|
|
86
|
+
const { id, ...body } = row;
|
|
87
|
+
guard(pruneId(body as Omit<PruneRow, "id">) === id);
|
|
88
|
+
return structuredClone(row) as unknown as PruneRow;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function sources(snapshot: PruneSnapshot): Map<string, PruneSourceRow> {
|
|
92
|
+
const result = new Map<string, PruneSourceRow>();
|
|
93
|
+
for (const source of snapshot.rows) {
|
|
94
|
+
const row = source.row, previous = result.get(row.id);
|
|
95
|
+
guard(fullId(row.id) && utc(row.at) && row.schema === "agent-hub.memory/v1");
|
|
96
|
+
const { id, ...body } = row;
|
|
97
|
+
guard(entryId(body) === id);
|
|
98
|
+
guard(!previous || canonicalJSON(previous.row) === canonicalJSON(row));
|
|
99
|
+
// Any verified public occurrence establishes provenance; local-only occurrences never establish it.
|
|
100
|
+
if (!previous || source.publicWitness && source.placement === "synced") result.set(row.id, source);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
function proof(target: PruneTarget, row: PruneRow, all: Map<string, PruneSourceRow>): "valid" | "pending" | "invalid" {
|
|
105
|
+
const note = all.get(target.id)?.row, basis = all.get(target.basis.id)?.row;
|
|
106
|
+
if (!note || !basis) return "pending";
|
|
107
|
+
if (note.op !== "note" || pruneRowHash(note) !== target.rowHash || pruneRowHash(basis) !== target.basis.rowHash || note.at > row.cutoff || basis.at > row.cutoff || basis.op !== target.basis.kind) return "invalid";
|
|
108
|
+
if (basis.op === "note") return basis.id === note.id && utc(basis.expires) && basis.expires === target.expiredAt ? "valid" : "invalid";
|
|
109
|
+
if (basis.op === "demote") return basis.target === note.id && utc(basis.by) && basis.by === target.expiredAt ? "valid" : "invalid";
|
|
110
|
+
if (basis.op === "settle") return !!note.task && basis.target === note.task && utc(basis.by) && basis.by === target.expiredAt ? "valid" : "invalid";
|
|
111
|
+
return "invalid";
|
|
112
|
+
}
|
|
113
|
+
/** Pure exact-entry reducer. No clock, writer policy, ancestry traversal, append or mutation occurs here. */
|
|
114
|
+
export function reducePruneRows(rows: readonly { row: unknown; placement: PrunePlacement }[], snapshot: PruneSnapshot, generation: string): { targets: Set<string>; pending: string[]; invalid: number } {
|
|
115
|
+
const targets = new Set<string>(), pending: string[] = [];
|
|
116
|
+
let invalid = 0;
|
|
117
|
+
const all = sources(snapshot);
|
|
118
|
+
for (const input of rows) {
|
|
119
|
+
let row: PruneRow;
|
|
120
|
+
try { row = validatePruneRow(input.row, generation, input.placement); } catch { invalid++; continue; }
|
|
121
|
+
const states = row.targets.map(target => proof(target, row, all));
|
|
122
|
+
if (states.includes("invalid")) { invalid++; continue; }
|
|
123
|
+
if (states.includes("pending")) { pending.push(row.id); continue; }
|
|
124
|
+
if (row.placement === "synced" && row.targets.some(target => [target.id, target.basis.id].some(id => !all.get(id)?.publicWitness || all.get(id)?.placement !== "synced"))) { invalid++; continue; }
|
|
125
|
+
for (const target of row.targets) targets.add(target.id);
|
|
126
|
+
}
|
|
127
|
+
return { targets, pending: [...new Set(pending)].sort(), invalid };
|
|
128
|
+
}
|
|
129
|
+
function authenticate(port: PruneStorePort, auth: PruneAuthorization): void {
|
|
130
|
+
guard(validGeneration(port.generation) && /^[a-f0-9]{16}$/.test(port.machine) && revision(port.selectorRevision()));
|
|
131
|
+
guard(auth.actor === "person" && typeof auth.authorize === "function" && auth.authorize() === true && typeof auth.project === "string" && auth.project.length > 0);
|
|
132
|
+
}
|
|
133
|
+
function policyRevision(auth: PruneAuthorization): string {
|
|
134
|
+
if (!auth.policy) return "local-only";
|
|
135
|
+
const current = auth.policy.revision();
|
|
136
|
+
guard(typeof current === "string" && current.length > 0 && Buffer.byteLength(current, "utf8") <= 1024 && current !== "local-only");
|
|
137
|
+
return current;
|
|
138
|
+
}
|
|
139
|
+
function visible(note: NoteEntry, project: string): boolean { return note.project === project || note.scope === "shared"; }
|
|
140
|
+
function targetFor(id: string, snapshot: PruneSnapshot, all: Map<string, PruneSourceRow>, cutoff: string, project: string): PruneTarget | undefined {
|
|
141
|
+
const found = all.get(id), expiry = snapshot.expiries.find(item => item.targetId === id);
|
|
142
|
+
if (!found || found.row.op !== "note" || !visible(found.row, project) || !expiry || !utc(expiry.expiredAt) || expiry.expiredAt > cutoff) return undefined;
|
|
143
|
+
const basis = all.get(expiry.basisId)?.row;
|
|
144
|
+
if (!basis || !["note", "demote", "settle"].includes(basis.op)) return undefined;
|
|
145
|
+
const target: PruneTarget = { id, rowHash: pruneRowHash(found.row), expiredAt: expiry.expiredAt, basis: { kind: basis.op as "note" | "demote" | "settle", id: basis.id, rowHash: pruneRowHash(basis) } };
|
|
146
|
+
const shell = { cutoff } as PruneRow;
|
|
147
|
+
return proof(target, shell, all) === "valid" ? target : undefined;
|
|
148
|
+
}
|
|
149
|
+
function placementFor(target: PruneTarget, all: Map<string, PruneSourceRow>, auth: PruneAuthorization): PrunePlacement {
|
|
150
|
+
if (!auth.policy) return "local";
|
|
151
|
+
const sources = [all.get(target.id)!, all.get(target.basis.id)!];
|
|
152
|
+
const verdicts = sources.map(source => auth.policy!.classify(structuredClone(source)));
|
|
153
|
+
guard(verdicts.every(value => ["public", "private", "unknown"].includes(value)));
|
|
154
|
+
return sources.every(source => source.placement === "synced" && source.publicWitness) && verdicts.every(verdict => verdict === "public") ? "synced" : "local";
|
|
155
|
+
}
|
|
156
|
+
function attest(row: PruneRow, auth: PruneAuthorization, expectedRevision: string): void {
|
|
157
|
+
guard(policyRevision(auth) === expectedRevision);
|
|
158
|
+
if (row.placement === "synced") guard(!!auth.policy && auth.policy.attest({ generation: row.generation, project: auth.project, payloadHash: prunePayloadHash(row), policyRevision: expectedRevision }) === true);
|
|
159
|
+
guard(policyRevision(auth) === expectedRevision);
|
|
160
|
+
}
|
|
161
|
+
function sealPlan(plan: Omit<PrunePlan, "seal">): PrunePlan { return { ...plan, seal: hashJSON(plan) }; }
|
|
162
|
+
function checkPlanSize(plan: PrunePlan): boolean { return bytes(plan) <= PRUNE_PLAN_MAX_BYTES && plan.rows.length <= PRUNE_PLAN_MAX_ROWS && plan.counts.selected <= PRUNE_PLAN_MAX_TARGETS; }
|
|
163
|
+
|
|
164
|
+
export function planPrune(port: PruneStorePort, input: { cutoff: string; targets?: readonly string[] }, auth: PruneAuthorization): PrunePlan {
|
|
165
|
+
return protectedCall(() => {
|
|
166
|
+
authenticate(port, auth); guard(utc(input.cutoff) && input.cutoff <= new Date(port.now()).toISOString());
|
|
167
|
+
const explicit = input.targets !== undefined;
|
|
168
|
+
if (explicit) guard(Array.isArray(input.targets) && input.targets!.length >= 1 && input.targets!.length <= PRUNE_PLAN_MAX_TARGETS && input.targets!.every(fullId) && new Set(input.targets).size === input.targets!.length);
|
|
169
|
+
const snapshot = port.snapshot(), all = sources(snapshot), already = new Set(snapshot.pruned ?? []);
|
|
170
|
+
const ids = explicit ? [...input.targets!] : snapshot.expiries.map(expiry => expiry.targetId).filter(id => !already.has(id));
|
|
171
|
+
const candidates: { target: PruneTarget; placement: PrunePlacement }[] = [];
|
|
172
|
+
for (const id of [...new Set(ids)].sort()) {
|
|
173
|
+
const target = targetFor(id, snapshot, all, input.cutoff, auth.project);
|
|
174
|
+
if (!target || already.has(id)) { if (explicit) fail(); else continue; }
|
|
175
|
+
guard(target !== undefined);
|
|
176
|
+
candidates.push({ target, placement: placementFor(target, all, auth) });
|
|
177
|
+
}
|
|
178
|
+
const currentPolicy = policyRevision(auth), selector = port.selectorRevision(), at = new Date(port.now()).toISOString();
|
|
179
|
+
const build = (selected: typeof candidates): PrunePlan => {
|
|
180
|
+
const rows: PrunePlan["rows"] = [];
|
|
181
|
+
for (const placement of ["local", "synced"] as const) {
|
|
182
|
+
const targets = selected.filter(item => item.placement === placement).map(item => item.target);
|
|
183
|
+
for (let index = 0; index < targets.length; index += PRUNE_ROW_MAX_TARGETS) {
|
|
184
|
+
const body: Omit<PruneRow, "id"> = { schema: "agent-mem.prune/v1", op: "prune", generation: port.generation, at, machine: port.machine, actor: "person", placement, cutoff: input.cutoff, targets: targets.slice(index, index + PRUNE_ROW_MAX_TARGETS) };
|
|
185
|
+
const payload = validatePruneRow({ ...body, id: pruneId(body) }); rows.push({ placement, payload });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return sealPlan({ schema: "agent-mem.prune-plan/v1", generation: port.generation, selectorRevision: selector, project: auth.project, policyRevision: currentPolicy, rows,
|
|
189
|
+
counts: { eligible: candidates.length, selected: selected.length, local: selected.filter(item => item.placement === "local").length, synced: selected.filter(item => item.placement === "synced").length }, hasMore: selected.length < candidates.length });
|
|
190
|
+
};
|
|
191
|
+
let selected = candidates.slice(0, PRUNE_PLAN_MAX_TARGETS), plan = build(selected);
|
|
192
|
+
while (!checkPlanSize(plan) && selected.length) { if (explicit) fail(); selected = selected.slice(0, -1); plan = build(selected); }
|
|
193
|
+
guard(checkPlanSize(plan));
|
|
194
|
+
for (const row of plan.rows) attest(row.payload, auth, currentPolicy);
|
|
195
|
+
guard(port.selectorRevision() === selector && policyRevision(auth) === currentPolicy);
|
|
196
|
+
return plan;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function validatePrunePlan(input: unknown): PrunePlan {
|
|
201
|
+
return protectedCall(() => {
|
|
202
|
+
if (typeof input === "string") { guard(Buffer.byteLength(input, "utf8") <= PRUNE_PLAN_MAX_BYTES); input = JSON.parse(input); }
|
|
203
|
+
guard(object(input) && bytes(input) <= PRUNE_PLAN_MAX_BYTES);
|
|
204
|
+
const p = input as Record<string, unknown>;
|
|
205
|
+
guard(exact(p, ["schema", "generation", "selectorRevision", "project", "policyRevision", "rows", "counts", "hasMore", "seal"]));
|
|
206
|
+
guard(p.schema === "agent-mem.prune-plan/v1" && validGeneration(p.generation) && revision(p.selectorRevision) && typeof p.project === "string" && p.project.length > 0 && typeof p.policyRevision === "string" && p.policyRevision.length > 0 && typeof p.hasMore === "boolean" && sha(p.seal));
|
|
207
|
+
guard(Array.isArray(p.rows) && p.rows.length <= PRUNE_PLAN_MAX_ROWS && object(p.counts) && exact(p.counts, ["eligible", "selected", "local", "synced"]));
|
|
208
|
+
const counts = p.counts as Record<string, unknown>;
|
|
209
|
+
guard(Object.values(counts).every(value => Number.isSafeInteger(value) && Number(value) >= 0));
|
|
210
|
+
let total = 0, local = 0, previous = "";
|
|
211
|
+
const seen = new Set<string>();
|
|
212
|
+
for (const value of p.rows as unknown[]) {
|
|
213
|
+
guard(object(value) && exact(value, ["placement", "payload"]) && ["local", "synced"].includes(String(value.placement)));
|
|
214
|
+
const row = validatePruneRow(value.payload, p.generation as string, value.placement as PrunePlacement);
|
|
215
|
+
const order = `${row.placement === "local" ? "0" : "1"}:${row.targets[0]!.id}`; guard(order > previous); previous = order;
|
|
216
|
+
for (const target of row.targets) { guard(!seen.has(target.id)); seen.add(target.id); }
|
|
217
|
+
total += row.targets.length; if (row.placement === "local") local += row.targets.length;
|
|
218
|
+
}
|
|
219
|
+
guard(total <= PRUNE_PLAN_MAX_TARGETS && total === counts.selected && local === counts.local && total - local === counts.synced && Number(counts.eligible) >= total && p.hasMore === (Number(counts.eligible) > total));
|
|
220
|
+
const { seal, ...body } = p; guard(hashJSON(body) === seal);
|
|
221
|
+
return structuredClone(p) as unknown as PrunePlan;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
export function applyPrune(port: PruneStorePort, input: unknown, auth: PruneAuthorization): { completed: string[] } {
|
|
225
|
+
return protectedCall(() => {
|
|
226
|
+
authenticate(port, auth);
|
|
227
|
+
const plan = validatePrunePlan(input);
|
|
228
|
+
return port.withMutation(() => {
|
|
229
|
+
authenticate(port, auth);
|
|
230
|
+
guard(plan.generation === port.generation && plan.project === auth.project && plan.selectorRevision === port.selectorRevision() && plan.policyRevision === policyRevision(auth));
|
|
231
|
+
const verify = () => {
|
|
232
|
+
const snapshot = port.snapshot(), all = sources(snapshot), durable = new Set(snapshot.durablePruneIds ?? []);
|
|
233
|
+
for (const item of plan.rows) {
|
|
234
|
+
guard(item.payload.machine === port.machine);
|
|
235
|
+
if (durable.has(item.payload.id)) continue;
|
|
236
|
+
for (const target of item.payload.targets) {
|
|
237
|
+
const current = targetFor(target.id, snapshot, all, item.payload.cutoff, auth.project);
|
|
238
|
+
guard(!!current && canonicalJSON(current) === canonicalJSON(target) && placementFor(target, all, auth) === item.placement);
|
|
239
|
+
}
|
|
240
|
+
guard(item.payload.at <= new Date(port.now()).toISOString());
|
|
241
|
+
attest(item.payload, auth, plan.policyRevision);
|
|
242
|
+
}
|
|
243
|
+
guard(plan.selectorRevision === port.selectorRevision() && plan.policyRevision === policyRevision(auth));
|
|
244
|
+
return durable;
|
|
245
|
+
};
|
|
246
|
+
verify(); verify(); // A policy callback can mutate external state; re-read immutable proofs before effects.
|
|
247
|
+
port.persistPrunePlan(plan);
|
|
248
|
+
const completed: string[] = [];
|
|
249
|
+
for (const item of plan.rows) {
|
|
250
|
+
verify();
|
|
251
|
+
// Parsed complete bytes are not proof of a successful fsync; the backend must reconfirm durability.
|
|
252
|
+
port.appendPrune(item.payload, canonicalJSON(item.payload) + "\n", plan.seal);
|
|
253
|
+
completed.push(item.payload.id);
|
|
254
|
+
}
|
|
255
|
+
port.refresh();
|
|
256
|
+
return { completed };
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|