@remembrance-ai/opencode-plugin 0.1.73 → 0.1.74
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
CHANGED
|
@@ -102,7 +102,9 @@ local MCP server both read `~/.config/remembrance/config.json`, an unset
|
|
|
102
102
|
After restarting, run MCP `run_connection_doctor` and require
|
|
103
103
|
`safe_to_query: true`. It verifies the active connection and gives one exact
|
|
104
104
|
next step if attention is required. Use `get_connection_status` only for lower-
|
|
105
|
-
level fields.
|
|
105
|
+
level fields. Fresh tool-observer and completion events are informational until
|
|
106
|
+
a later lifecycle opportunity is actually missed. Never infer scope from
|
|
107
|
+
`REMEMBRANCE_API_KEY` alone, from an environment
|
|
106
108
|
variable, or from an anonymous REST/browser probe — the diagnostic verifies the
|
|
107
109
|
process that will actually serve opencode's tools, without exposing the key.
|
|
108
110
|
|
|
@@ -154,7 +156,9 @@ routine organization lesson in memory and stores only an encrypted
|
|
|
154
156
|
post-redaction draft. `submit_private_lesson_candidate` is the separate network
|
|
155
157
|
action; it sends those exact bytes only to the authenticated organization's
|
|
156
158
|
private verifier queue and can never create or automatically propagate public
|
|
157
|
-
content.
|
|
159
|
+
content. Unresolved and terminal drafts never expire or auto-delete. Verified
|
|
160
|
+
submissions discard encrypted lesson content immediately; their content-free
|
|
161
|
+
completion markers are automatically deleted after 14 days.
|
|
158
162
|
|
|
159
163
|
The signed policy pins the corrected `private-lesson-redaction-v2` profile and
|
|
160
164
|
its exact digest. Unsupported-profile drafts become terminal
|
package/opencode.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"command": [
|
|
7
7
|
"npx",
|
|
8
8
|
"-y",
|
|
9
|
-
"@remembrance-ai/mcp-server@0.1.
|
|
9
|
+
"@remembrance-ai/mcp-server@0.1.74"
|
|
10
10
|
],
|
|
11
11
|
"enabled": true,
|
|
12
12
|
"environment": {
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"plugin": [
|
|
19
|
-
"@remembrance-ai/opencode-plugin@0.1.
|
|
19
|
+
"@remembrance-ai/opencode-plugin@0.1.74"
|
|
20
20
|
]
|
|
21
21
|
}
|
package/package.json
CHANGED
package/scripts/hook-core.mjs
CHANGED
|
@@ -3124,7 +3124,7 @@ export function privateLessonLifecycleRecoveryInstruction(env = process.env) {
|
|
|
3124
3124
|
return [
|
|
3125
3125
|
`Private lesson lifecycle recovery: ${pending} retained local draft${pending === 1 ? " needs" : "s need"} attention. Silently call inspect_private_lesson_outbox.`,
|
|
3126
3126
|
"Call submit_private_lesson_candidate for each ready candidate or safety-held draft with pending content-free telemetry. Call retry_private_lesson_candidate only when the candidate or telemetry retry is due.",
|
|
3127
|
-
"Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete
|
|
3127
|
+
"Do not retry awaiting_authorization, disabled telemetry, submitted telemetry, or superseded-redactor drafts; do not use another transport, and never delete an unresolved draft automatically. Verified submissions compact automatically and their content-free markers expire after 14 days. Hide routine results.",
|
|
3128
3128
|
].join("\n");
|
|
3129
3129
|
}
|
|
3130
3130
|
|
|
@@ -5073,6 +5073,18 @@ function pluginLifecycleCheck(pluginHealth, issues) {
|
|
|
5073
5073
|
"The native plugin startup and prompt lifecycle is active."
|
|
5074
5074
|
);
|
|
5075
5075
|
}
|
|
5076
|
+
if (pluginHealth.status === "pending") {
|
|
5077
|
+
const pending = Array.isArray(pluginHealth.pending_components) ? pluginHealth.pending_components.filter(
|
|
5078
|
+
(component) => component === "tool_observer" || component === "completion_hook"
|
|
5079
|
+
) : [];
|
|
5080
|
+
const pendingSummary = pending.map(
|
|
5081
|
+
(component) => component === "tool_observer" ? "tool observation" : "completion"
|
|
5082
|
+
).join(" and ");
|
|
5083
|
+
return info(
|
|
5084
|
+
"plugin_lifecycle",
|
|
5085
|
+
`The native plugin startup and prompt lifecycle is active; ${pendingSummary || "remaining lifecycle"} verification is pending until ${pending.length === 1 ? "that event has" : "those events have"} an opportunity to run.`
|
|
5086
|
+
);
|
|
5087
|
+
}
|
|
5076
5088
|
if (stringValue(pluginHealth.surface) === "codex" && issues.includes("hook_trust_required")) {
|
|
5077
5089
|
const hookTrust = record(pluginHealth.hook_trust);
|
|
5078
5090
|
const allowedEvents = /* @__PURE__ */ new Set([
|
|
@@ -5099,7 +5111,7 @@ function pluginLifecycleCheck(pluginHealth, issues) {
|
|
|
5099
5111
|
const codex = stringValue(pluginHealth.surface) === "codex";
|
|
5100
5112
|
return warning(
|
|
5101
5113
|
"plugin_lifecycle",
|
|
5102
|
-
"The core native lifecycle is active
|
|
5114
|
+
"The core native lifecycle is active, but a tool-observer or completion event did not run after an observed opportunity.",
|
|
5103
5115
|
"exercise_plugin_lifecycle",
|
|
5104
5116
|
codex ? `Use one Remembrance tool, complete one turn, and rerun the doctor. If this warning remains, ${codexHookReviewAction("run_connection_doctor")}` : "Use one Remembrance tool, complete one turn, and rerun the doctor.",
|
|
5105
5117
|
issues,
|
|
@@ -5180,6 +5192,9 @@ function issueCodes(value) {
|
|
|
5180
5192
|
function pass(id, summary) {
|
|
5181
5193
|
return { id, status: "pass", summary, remediation: null };
|
|
5182
5194
|
}
|
|
5195
|
+
function info(id, summary) {
|
|
5196
|
+
return { id, status: "info", summary, remediation: null };
|
|
5197
|
+
}
|
|
5183
5198
|
function notApplicable(id, summary) {
|
|
5184
5199
|
return { id, status: "not_applicable", summary, remediation: null };
|
|
5185
5200
|
}
|
|
@@ -7595,7 +7610,7 @@ var seedSkills = [
|
|
|
7595
7610
|
summary: "Operational setup and troubleshooting workflow for Remembrance across Claude Code, Codex, OpenClaw, Cursor, Gemini, MCP, REST, skill-only installs, enterprise keys, and local agent identity.",
|
|
7596
7611
|
status: "active",
|
|
7597
7612
|
visibility: "public",
|
|
7598
|
-
version: "0.1.
|
|
7613
|
+
version: "0.1.24",
|
|
7599
7614
|
domains: ["agent-skills", "mcp", "resource-discovery"],
|
|
7600
7615
|
tags: [
|
|
7601
7616
|
"remembrance",
|
|
@@ -7646,7 +7661,7 @@ var seedSkills = [
|
|
|
7646
7661
|
"gemini",
|
|
7647
7662
|
"rest"
|
|
7648
7663
|
],
|
|
7649
|
-
version: "0.1.
|
|
7664
|
+
version: "0.1.24",
|
|
7650
7665
|
status: "active",
|
|
7651
7666
|
visibility: "public",
|
|
7652
7667
|
providers: ["codex", "claude", "cursor", "openclaw", "generic"],
|
|
@@ -8009,8 +8024,11 @@ draft remains on the device in \`awaiting_authorization\`; do not retry it
|
|
|
8009
8024
|
through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
|
|
8010
8025
|
responses retry with bounded backoff during later plugin lifecycles. A 401,
|
|
8011
8026
|
403, policy change, or validation failure remains held for explicit repair.
|
|
8012
|
-
|
|
8013
|
-
with the local private-lesson tools; deletion requires
|
|
8027
|
+
Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
|
|
8028
|
+
explicitly delete one with the local private-lesson tools; deletion requires
|
|
8029
|
+
confirmation. After a signed submission receipt is verified, the encrypted
|
|
8030
|
+
lesson content is removed immediately and its content-free completion marker
|
|
8031
|
+
is automatically deleted after 14 days.
|
|
8014
8032
|
|
|
8015
8033
|
The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
|
|
8016
8034
|
supported redactor digest. If either is unsupported, the finalized
|
|
@@ -11053,8 +11071,16 @@ function isAlreadyExistsError(error) {
|
|
|
11053
11071
|
}
|
|
11054
11072
|
|
|
11055
11073
|
// src/connection-status.ts
|
|
11056
|
-
import {
|
|
11057
|
-
|
|
11074
|
+
import {
|
|
11075
|
+
chmodSync,
|
|
11076
|
+
existsSync as existsSync2,
|
|
11077
|
+
lstatSync as lstatSync2,
|
|
11078
|
+
mkdirSync,
|
|
11079
|
+
renameSync,
|
|
11080
|
+
rmSync,
|
|
11081
|
+
writeFileSync
|
|
11082
|
+
} from "node:fs";
|
|
11083
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
11058
11084
|
import { isIP } from "node:net";
|
|
11059
11085
|
import { homedir as homedir3 } from "node:os";
|
|
11060
11086
|
import { join as join3 } from "node:path";
|
|
@@ -11349,6 +11375,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
|
|
|
11349
11375
|
"tool_observer",
|
|
11350
11376
|
"completion_hook"
|
|
11351
11377
|
];
|
|
11378
|
+
var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
|
|
11352
11379
|
var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
11353
11380
|
var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
11354
11381
|
var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -11371,6 +11398,57 @@ var nodeFileSystem2 = {
|
|
|
11371
11398
|
mode: secureLocalFileMode,
|
|
11372
11399
|
list: listSecureLocalDirectory
|
|
11373
11400
|
};
|
|
11401
|
+
function recordConnectionDoctorLifecycleProbe(options = {}) {
|
|
11402
|
+
const env = options.env ?? process.env;
|
|
11403
|
+
const rawSurface = String(env.REMEMBRANCE_PLUGIN_HOST ?? "").trim().toLowerCase();
|
|
11404
|
+
if (!isPluginHostSurface(rawSurface)) return false;
|
|
11405
|
+
const healthDir = env.REMEMBRANCE_PLUGIN_HEALTH_DIR ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR) : join3(
|
|
11406
|
+
options.homeDirectory ?? homedir3(),
|
|
11407
|
+
".cache",
|
|
11408
|
+
"remembrance",
|
|
11409
|
+
"plugin-health"
|
|
11410
|
+
);
|
|
11411
|
+
const path = join3(healthDir, `${rawSurface}.json`);
|
|
11412
|
+
if (!existsSync2(path)) return false;
|
|
11413
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
11414
|
+
try {
|
|
11415
|
+
const parsed = JSON.parse(
|
|
11416
|
+
readSecureLocalText(path, {
|
|
11417
|
+
maxBytes: MAX_LOCAL_LIFECYCLE_MARKER_BYTES
|
|
11418
|
+
})
|
|
11419
|
+
);
|
|
11420
|
+
if (!isRecord3(parsed) || parsed.surface !== rawSurface) return false;
|
|
11421
|
+
const components = isRecord3(parsed.components) ? parsed.components : {};
|
|
11422
|
+
if (typeof components.session_start !== "string") return false;
|
|
11423
|
+
mkdirSync(healthDir, { recursive: true, mode: 448 });
|
|
11424
|
+
const directoryMetadata = lstatSync2(healthDir);
|
|
11425
|
+
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
11426
|
+
return false;
|
|
11427
|
+
}
|
|
11428
|
+
chmodSync(healthDir, 448);
|
|
11429
|
+
writeFileSync(
|
|
11430
|
+
temporaryPath,
|
|
11431
|
+
`${JSON.stringify({
|
|
11432
|
+
...parsed,
|
|
11433
|
+
components: {
|
|
11434
|
+
...components,
|
|
11435
|
+
[CONNECTION_DOCTOR_PROBE_COMPONENT]: (options.now ?? /* @__PURE__ */ new Date()).toISOString()
|
|
11436
|
+
}
|
|
11437
|
+
})}
|
|
11438
|
+
`,
|
|
11439
|
+
{ mode: 384, flag: "wx" }
|
|
11440
|
+
);
|
|
11441
|
+
renameSync(temporaryPath, path);
|
|
11442
|
+
chmodSync(path, 384);
|
|
11443
|
+
return true;
|
|
11444
|
+
} catch {
|
|
11445
|
+
try {
|
|
11446
|
+
rmSync(temporaryPath, { force: true });
|
|
11447
|
+
} catch {
|
|
11448
|
+
}
|
|
11449
|
+
return false;
|
|
11450
|
+
}
|
|
11451
|
+
}
|
|
11374
11452
|
function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
|
|
11375
11453
|
const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
|
|
11376
11454
|
return join3(configRoot, "remembrance", "config.json");
|
|
@@ -11735,6 +11813,20 @@ function localPluginLifecycleHealth(options) {
|
|
|
11735
11813
|
}
|
|
11736
11814
|
if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
|
|
11737
11815
|
}
|
|
11816
|
+
let latestDoctorProbe;
|
|
11817
|
+
let latestDoctorProbeMs = Number.NEGATIVE_INFINITY;
|
|
11818
|
+
for (const entry of cohort) {
|
|
11819
|
+
const components2 = isRecord3(entry.marker.components) ? entry.marker.components : {};
|
|
11820
|
+
const value = components2[CONNECTION_DOCTOR_PROBE_COMPONENT];
|
|
11821
|
+
const valueMs = validLifecycleTimestamp(value, nowMs);
|
|
11822
|
+
if (valueMs > latestDoctorProbeMs) {
|
|
11823
|
+
latestDoctorProbeMs = valueMs;
|
|
11824
|
+
latestDoctorProbe = value;
|
|
11825
|
+
}
|
|
11826
|
+
}
|
|
11827
|
+
if (Number.isFinite(latestDoctorProbeMs)) {
|
|
11828
|
+
mergedComponents[CONNECTION_DOCTOR_PROBE_COMPONENT] = latestDoctorProbe;
|
|
11829
|
+
}
|
|
11738
11830
|
const marker = {
|
|
11739
11831
|
...newestEntry.marker,
|
|
11740
11832
|
components: mergedComponents,
|
|
@@ -11772,6 +11864,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11772
11864
|
const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
|
|
11773
11865
|
const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
|
|
11774
11866
|
const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
|
|
11867
|
+
const pendingComponents = [];
|
|
11775
11868
|
if (hookTrustReviewEvents.size > 0) {
|
|
11776
11869
|
issues.push({
|
|
11777
11870
|
code: "hook_trust_required",
|
|
@@ -11780,17 +11873,40 @@ function localPluginLifecycleHealth(options) {
|
|
|
11780
11873
|
])
|
|
11781
11874
|
});
|
|
11782
11875
|
}
|
|
11783
|
-
if (coreLifecycleObserved && !
|
|
11784
|
-
|
|
11785
|
-
|
|
11786
|
-
|
|
11787
|
-
|
|
11876
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("PostToolUse")) {
|
|
11877
|
+
const toolObserverMs = validLifecycleTimestamp(
|
|
11878
|
+
componentRecord.tool_observer,
|
|
11879
|
+
nowMs
|
|
11880
|
+
);
|
|
11881
|
+
const missedProbe = Number.isFinite(latestDoctorProbeMs) && (!Number.isFinite(toolObserverMs) || toolObserverMs < latestDoctorProbeMs);
|
|
11882
|
+
if (missedProbe) {
|
|
11883
|
+
issues.push({
|
|
11884
|
+
code: "tool_observer_not_observed",
|
|
11885
|
+
action: surface === "codex" ? `A prior doctor call completed without a later tool-observer event. ${codexHookReviewAction("get_connection_status")}` : "A prior doctor call completed without a later native tool-observer event. Update or reinstall the plugin, fully restart the host, and retry."
|
|
11886
|
+
});
|
|
11887
|
+
} else if (!components.tool_observer.observed) {
|
|
11888
|
+
pendingComponents.push("tool_observer");
|
|
11889
|
+
}
|
|
11788
11890
|
}
|
|
11789
|
-
if (coreLifecycleObserved && !
|
|
11790
|
-
|
|
11791
|
-
|
|
11792
|
-
|
|
11793
|
-
|
|
11891
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("Stop")) {
|
|
11892
|
+
const promptHookMs = validLifecycleTimestamp(
|
|
11893
|
+
componentRecord.prompt_hook,
|
|
11894
|
+
nowMs
|
|
11895
|
+
);
|
|
11896
|
+
const completionHookMs = validLifecycleTimestamp(
|
|
11897
|
+
componentRecord.completion_hook,
|
|
11898
|
+
nowMs
|
|
11899
|
+
);
|
|
11900
|
+
const completedTurnOpportunity = Number.isFinite(latestDoctorProbeMs) && Number.isFinite(promptHookMs) && promptHookMs > latestDoctorProbeMs;
|
|
11901
|
+
const missedCompletedTurn = completedTurnOpportunity && (!Number.isFinite(completionHookMs) || completionHookMs < latestDoctorProbeMs);
|
|
11902
|
+
if (missedCompletedTurn) {
|
|
11903
|
+
issues.push({
|
|
11904
|
+
code: "completion_hook_not_observed",
|
|
11905
|
+
action: surface === "codex" ? `A new prompt arrived after a prior doctor call without an intervening completion event. ${codexHookReviewAction("get_connection_status")}` : "A new prompt arrived after a prior doctor call without an intervening native completion event. Update or reinstall the plugin, fully restart the host, and retry."
|
|
11906
|
+
});
|
|
11907
|
+
} else if (!components.completion_hook.observed) {
|
|
11908
|
+
pendingComponents.push("completion_hook");
|
|
11909
|
+
}
|
|
11794
11910
|
}
|
|
11795
11911
|
const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
|
|
11796
11912
|
const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
|
|
@@ -11834,7 +11950,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11834
11950
|
);
|
|
11835
11951
|
return {
|
|
11836
11952
|
expected: true,
|
|
11837
|
-
status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11953
|
+
status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11838
11954
|
surface,
|
|
11839
11955
|
plugin_version: markerVersion || null,
|
|
11840
11956
|
host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
|
|
@@ -11847,6 +11963,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11847
11963
|
},
|
|
11848
11964
|
hook_trust: hookTrust,
|
|
11849
11965
|
components,
|
|
11966
|
+
pending_components: pendingComponents,
|
|
11850
11967
|
issues,
|
|
11851
11968
|
explanation: "Each host process session is tracked independently. Turn-specific prompt, tool, and completion observations are combined only when they share the current SessionStart marker."
|
|
11852
11969
|
};
|
|
@@ -12090,7 +12207,7 @@ async function checkClientUpdate(input) {
|
|
|
12090
12207
|
}
|
|
12091
12208
|
|
|
12092
12209
|
// src/private-lesson-outbox.ts
|
|
12093
|
-
import { createCipheriv, createDecipheriv, randomBytes as
|
|
12210
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
|
|
12094
12211
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12095
12212
|
import {
|
|
12096
12213
|
chmod as chmod2,
|
|
@@ -12106,7 +12223,7 @@ import { homedir as homedir4 } from "node:os";
|
|
|
12106
12223
|
import { dirname as dirname2, join as join4 } from "node:path";
|
|
12107
12224
|
|
|
12108
12225
|
// src/private-lesson-keychain.ts
|
|
12109
|
-
import { randomBytes } from "node:crypto";
|
|
12226
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
12110
12227
|
import { execFileSync } from "node:child_process";
|
|
12111
12228
|
import { existsSync as existsSync3 } from "node:fs";
|
|
12112
12229
|
var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
|
|
@@ -12148,7 +12265,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
|
|
|
12148
12265
|
if (!keychainEnabled(env, dependencies)) return null;
|
|
12149
12266
|
const existing = readPrivateLessonKeychainKey(env, dependencies);
|
|
12150
12267
|
if (existing) return existing;
|
|
12151
|
-
const encoded = dependencies.randomKey ??
|
|
12268
|
+
const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
|
|
12152
12269
|
const generated = decodeKey(encoded);
|
|
12153
12270
|
if (!generated) return null;
|
|
12154
12271
|
try {
|
|
@@ -12184,9 +12301,11 @@ function decodeKey(encoded) {
|
|
|
12184
12301
|
// src/private-lesson-outbox.ts
|
|
12185
12302
|
var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
|
|
12186
12303
|
var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
|
|
12304
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
12187
12305
|
var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
|
|
12188
12306
|
var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
|
|
12189
12307
|
var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
|
|
12308
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
|
|
12190
12309
|
var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
|
|
12191
12310
|
var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
|
|
12192
12311
|
var privateLessonOutboxLockContext = new AsyncLocalStorage();
|
|
@@ -12254,6 +12373,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12254
12373
|
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12255
12374
|
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12256
12375
|
}
|
|
12376
|
+
if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
|
|
12377
|
+
throw new Error("Private lesson draft was already submitted.");
|
|
12378
|
+
}
|
|
12257
12379
|
const path = draftPath(outbox.directory, draftId);
|
|
12258
12380
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12259
12381
|
const serialized = await readBoundedFile(
|
|
@@ -12272,10 +12394,39 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12272
12394
|
return reconciled;
|
|
12273
12395
|
});
|
|
12274
12396
|
}
|
|
12397
|
+
async function readPrivateLessonSubmittedRecord(draftId, options = {}) {
|
|
12398
|
+
assertDraftId(draftId);
|
|
12399
|
+
const directory = options.outboxDirectory ?? privateLessonOutboxDirectory(options.env, options.homeDirectory);
|
|
12400
|
+
if (!existsSync4(directory)) return null;
|
|
12401
|
+
assertPrivateLessonPathMetadata(await lstat(directory), "directory");
|
|
12402
|
+
return withPrivateLessonOutboxLock(directory, async () => {
|
|
12403
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12404
|
+
directory,
|
|
12405
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12406
|
+
);
|
|
12407
|
+
if (existsSync4(tombstonePath(directory, draftId))) {
|
|
12408
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12409
|
+
}
|
|
12410
|
+
const path = submittedRecordPath(directory, draftId);
|
|
12411
|
+
if (!existsSync4(path)) return null;
|
|
12412
|
+
await assertRegularPrivateFile(
|
|
12413
|
+
path,
|
|
12414
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12415
|
+
);
|
|
12416
|
+
return parseSubmittedRecord(
|
|
12417
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12418
|
+
draftId
|
|
12419
|
+
);
|
|
12420
|
+
});
|
|
12421
|
+
}
|
|
12275
12422
|
async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
12276
12423
|
const outbox = await readyOutbox(options);
|
|
12277
12424
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12278
12425
|
await reconcileExplicitDeletionTombstones(outbox.directory);
|
|
12426
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12427
|
+
outbox.directory,
|
|
12428
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12429
|
+
);
|
|
12279
12430
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12280
12431
|
let inspected = 0;
|
|
12281
12432
|
let terminalized = 0;
|
|
@@ -12296,10 +12447,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
|
12296
12447
|
encryption: outbox.encryption
|
|
12297
12448
|
});
|
|
12298
12449
|
inspected += 1;
|
|
12450
|
+
if (draft.state === "submitted") {
|
|
12451
|
+
await compactLegacySubmittedDraftUnlocked(draft, outbox);
|
|
12452
|
+
continue;
|
|
12453
|
+
}
|
|
12299
12454
|
if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
|
|
12300
12455
|
terminalized += 1;
|
|
12301
12456
|
}
|
|
12302
12457
|
}
|
|
12458
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12459
|
+
outbox.directory,
|
|
12460
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12461
|
+
);
|
|
12303
12462
|
return { inspected, terminalized };
|
|
12304
12463
|
});
|
|
12305
12464
|
}
|
|
@@ -12313,8 +12472,22 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12313
12472
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12314
12473
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
12315
12474
|
const drafts = [];
|
|
12316
|
-
for (const name of entries.filter(
|
|
12475
|
+
for (const name of entries.filter(
|
|
12476
|
+
(item) => item.endsWith(".lesson.json") || item.endsWith(".submitted.json")
|
|
12477
|
+
)) {
|
|
12317
12478
|
if (drafts.length >= limit) break;
|
|
12479
|
+
if (name.endsWith(".submitted.json")) {
|
|
12480
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12481
|
+
const completion = parseSubmittedRecord(
|
|
12482
|
+
await readBoundedFile(
|
|
12483
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12484
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12485
|
+
),
|
|
12486
|
+
draftId
|
|
12487
|
+
);
|
|
12488
|
+
drafts.push(submittedRecordListItem(completion));
|
|
12489
|
+
continue;
|
|
12490
|
+
}
|
|
12318
12491
|
const draft = await readPrivateLessonDraft(
|
|
12319
12492
|
name.slice(0, -".lesson.json".length),
|
|
12320
12493
|
{
|
|
@@ -12333,6 +12506,7 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12333
12506
|
created_at: draft.created_at,
|
|
12334
12507
|
updated_at: draft.updated_at,
|
|
12335
12508
|
submitted_at: draft.submitted_at,
|
|
12509
|
+
completion_expires_at: null,
|
|
12336
12510
|
terminal_reason: draft.terminal_reason,
|
|
12337
12511
|
hold_telemetry_status: draft.hold_telemetry_status,
|
|
12338
12512
|
hold_event_digest: draft.hold_event_digest,
|
|
@@ -12360,6 +12534,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
|
|
|
12360
12534
|
return next;
|
|
12361
12535
|
});
|
|
12362
12536
|
}
|
|
12537
|
+
async function completePrivateLessonSubmission(draftId, input, options = {}) {
|
|
12538
|
+
assertDraftId(draftId);
|
|
12539
|
+
const outbox = await readyOutbox(options);
|
|
12540
|
+
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12541
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12542
|
+
outbox.directory,
|
|
12543
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12544
|
+
);
|
|
12545
|
+
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12546
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12547
|
+
}
|
|
12548
|
+
const existingPath = submittedRecordPath(outbox.directory, draftId);
|
|
12549
|
+
if (existsSync4(existingPath)) {
|
|
12550
|
+
const completion2 = parseSubmittedRecord(
|
|
12551
|
+
await readBoundedFile(
|
|
12552
|
+
existingPath,
|
|
12553
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12554
|
+
),
|
|
12555
|
+
draftId
|
|
12556
|
+
);
|
|
12557
|
+
if (completion2.receipt_digest !== input.receipt_digest) {
|
|
12558
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12559
|
+
}
|
|
12560
|
+
return completion2;
|
|
12561
|
+
}
|
|
12562
|
+
const path = draftPath(outbox.directory, draftId);
|
|
12563
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12564
|
+
const envelope = parseEnvelope(
|
|
12565
|
+
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
12566
|
+
draftId
|
|
12567
|
+
);
|
|
12568
|
+
const current = decryptDraft(envelope, outbox.encryption, true);
|
|
12569
|
+
if (current.state === "superseded_redactor") {
|
|
12570
|
+
throw new Error("Terminal private lesson drafts cannot be submitted.");
|
|
12571
|
+
}
|
|
12572
|
+
if (current.state === "submitted" && current.receipt_digest !== input.receipt_digest) {
|
|
12573
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12574
|
+
}
|
|
12575
|
+
const submittedAt = input.submitted_at ?? options.now ?? /* @__PURE__ */ new Date();
|
|
12576
|
+
const submittedIso = submittedAt.toISOString();
|
|
12577
|
+
const draft = current.state === "submitted" ? current : validateDraft({
|
|
12578
|
+
...current,
|
|
12579
|
+
state: "submitted",
|
|
12580
|
+
attempts: Math.min(1e3, current.attempts + 1),
|
|
12581
|
+
next_retry_at: null,
|
|
12582
|
+
last_error_code: null,
|
|
12583
|
+
submitted_at: submittedIso,
|
|
12584
|
+
receipt_digest: input.receipt_digest,
|
|
12585
|
+
updated_at: submittedIso
|
|
12586
|
+
});
|
|
12587
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
12588
|
+
remote_status: input.remote_status,
|
|
12589
|
+
processing_state: input.processing_state
|
|
12590
|
+
});
|
|
12591
|
+
await atomicWritePrivate(
|
|
12592
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12593
|
+
`${JSON.stringify(completion)}
|
|
12594
|
+
`
|
|
12595
|
+
);
|
|
12596
|
+
await unlink(path).catch(() => {
|
|
12597
|
+
});
|
|
12598
|
+
await syncDirectory(outbox.directory).catch(() => {
|
|
12599
|
+
});
|
|
12600
|
+
return completion;
|
|
12601
|
+
});
|
|
12602
|
+
}
|
|
12363
12603
|
async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
12364
12604
|
if (!confirmed) {
|
|
12365
12605
|
throw new Error(
|
|
@@ -12370,7 +12610,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12370
12610
|
const outbox = await readyOutbox(options);
|
|
12371
12611
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12372
12612
|
const path = draftPath(outbox.directory, draftId);
|
|
12373
|
-
|
|
12613
|
+
const completionPath = submittedRecordPath(outbox.directory, draftId);
|
|
12614
|
+
if (!existsSync4(path) && !existsSync4(completionPath)) {
|
|
12615
|
+
throw new Error("Private lesson draft does not exist.");
|
|
12616
|
+
}
|
|
12617
|
+
if (existsSync4(path)) {
|
|
12618
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12619
|
+
}
|
|
12620
|
+
if (existsSync4(completionPath)) {
|
|
12621
|
+
await assertRegularPrivateFile(
|
|
12622
|
+
completionPath,
|
|
12623
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12624
|
+
);
|
|
12625
|
+
}
|
|
12374
12626
|
const tombstone = {
|
|
12375
12627
|
version: "private-lesson-outbox-deletion-v1",
|
|
12376
12628
|
draft_id: draftId,
|
|
@@ -12382,7 +12634,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12382
12634
|
`${JSON.stringify(tombstone)}
|
|
12383
12635
|
`
|
|
12384
12636
|
);
|
|
12385
|
-
await unlink(path)
|
|
12637
|
+
await unlink(path).catch((error) => {
|
|
12638
|
+
if (!isMissingError(error)) throw error;
|
|
12639
|
+
});
|
|
12640
|
+
await unlink(completionPath).catch((error) => {
|
|
12641
|
+
if (!isMissingError(error)) throw error;
|
|
12642
|
+
});
|
|
12386
12643
|
await syncDirectory(outbox.directory);
|
|
12387
12644
|
return tombstone;
|
|
12388
12645
|
});
|
|
@@ -12415,6 +12672,15 @@ async function privateLessonOutboxSummary(options = {}) {
|
|
|
12415
12672
|
deletionTombstones += 1;
|
|
12416
12673
|
continue;
|
|
12417
12674
|
}
|
|
12675
|
+
if (name.endsWith(".submitted.json")) {
|
|
12676
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12677
|
+
parseSubmittedRecord(
|
|
12678
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12679
|
+
draftId
|
|
12680
|
+
);
|
|
12681
|
+
counts.submitted += 1;
|
|
12682
|
+
continue;
|
|
12683
|
+
}
|
|
12418
12684
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12419
12685
|
const envelope = parseEnvelope(
|
|
12420
12686
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12477,6 +12743,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12477
12743
|
outboxDirectory: directory,
|
|
12478
12744
|
encryption
|
|
12479
12745
|
});
|
|
12746
|
+
} else {
|
|
12747
|
+
await withPrivateLessonOutboxLock(
|
|
12748
|
+
directory,
|
|
12749
|
+
() => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
|
|
12750
|
+
);
|
|
12480
12751
|
}
|
|
12481
12752
|
const entries = await secureOutboxEntries(directory);
|
|
12482
12753
|
const counts = emptyOutboxCounts();
|
|
@@ -12492,6 +12763,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12492
12763
|
deletionTombstones += 1;
|
|
12493
12764
|
continue;
|
|
12494
12765
|
}
|
|
12766
|
+
if (name.endsWith(".submitted.json")) {
|
|
12767
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12768
|
+
parseSubmittedRecord(
|
|
12769
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12770
|
+
draftId
|
|
12771
|
+
);
|
|
12772
|
+
counts.submitted += 1;
|
|
12773
|
+
continue;
|
|
12774
|
+
}
|
|
12495
12775
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12496
12776
|
const envelope = parseEnvelope(
|
|
12497
12777
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12559,7 +12839,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
|
|
|
12559
12839
|
encoded = await readConcurrentPrivateLessonFallbackKey(path);
|
|
12560
12840
|
} catch (error) {
|
|
12561
12841
|
if (!isMissingError(error)) throw error;
|
|
12562
|
-
encoded =
|
|
12842
|
+
encoded = randomBytes3(32).toString("base64url");
|
|
12563
12843
|
try {
|
|
12564
12844
|
await atomicWritePrivate(path, `${encoded}
|
|
12565
12845
|
`);
|
|
@@ -12691,7 +12971,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
|
|
|
12691
12971
|
if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
|
|
12692
12972
|
const stalePath = join4(
|
|
12693
12973
|
directory,
|
|
12694
|
-
`.outbox.lock.stale-${
|
|
12974
|
+
`.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
|
|
12695
12975
|
);
|
|
12696
12976
|
try {
|
|
12697
12977
|
await rename(lockPath, stalePath);
|
|
@@ -12741,14 +13021,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
|
|
|
12741
13021
|
await readBoundedFile(join4(directory, name), 4 * 1024),
|
|
12742
13022
|
name.slice(0, -".deleted.json".length)
|
|
12743
13023
|
);
|
|
12744
|
-
const
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
13024
|
+
const retainedPaths = [
|
|
13025
|
+
{
|
|
13026
|
+
path: draftPath(directory, tombstone.draft_id),
|
|
13027
|
+
maxBytes: PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13028
|
+
},
|
|
13029
|
+
{
|
|
13030
|
+
path: submittedRecordPath(directory, tombstone.draft_id),
|
|
13031
|
+
maxBytes: PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
13032
|
+
}
|
|
13033
|
+
];
|
|
13034
|
+
for (const retained of retainedPaths) {
|
|
13035
|
+
if (!existsSync4(retained.path)) continue;
|
|
13036
|
+
await assertRegularPrivateFile(retained.path, retained.maxBytes);
|
|
13037
|
+
await unlink(retained.path);
|
|
13038
|
+
changed = true;
|
|
13039
|
+
}
|
|
12752
13040
|
}
|
|
12753
13041
|
if (changed) await syncDirectory(directory);
|
|
12754
13042
|
}
|
|
@@ -12779,7 +13067,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
|
|
|
12779
13067
|
if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
|
|
12780
13068
|
throw new Error("Private lesson draft exceeds its local size limit.");
|
|
12781
13069
|
}
|
|
12782
|
-
const nonce =
|
|
13070
|
+
const nonce = randomBytes3(12);
|
|
12783
13071
|
const aad = Buffer.from(
|
|
12784
13072
|
canonicalJson({
|
|
12785
13073
|
version: "private-lesson-outbox-envelope-v1",
|
|
@@ -12947,6 +13235,135 @@ function terminalizeUnsupportedProfile(draft, now) {
|
|
|
12947
13235
|
};
|
|
12948
13236
|
}
|
|
12949
13237
|
}
|
|
13238
|
+
function submittedRecordFromDraft(draft, remote) {
|
|
13239
|
+
if (draft.state !== "submitted" || !draft.submitted_at || !draft.receipt_digest) {
|
|
13240
|
+
throw new Error("Private lesson submission completion is incomplete.");
|
|
13241
|
+
}
|
|
13242
|
+
return parseSubmittedRecord(
|
|
13243
|
+
JSON.stringify({
|
|
13244
|
+
version: "private-lesson-outbox-submitted-v1",
|
|
13245
|
+
draft_id: draft.draft_id,
|
|
13246
|
+
state: "submitted",
|
|
13247
|
+
payload_digest: draft.payload_digest,
|
|
13248
|
+
redaction_version: draft.redaction_version,
|
|
13249
|
+
redactor_digest: draft.redactor_digest,
|
|
13250
|
+
attempts: draft.attempts,
|
|
13251
|
+
created_at: draft.created_at,
|
|
13252
|
+
updated_at: draft.updated_at,
|
|
13253
|
+
submitted_at: draft.submitted_at,
|
|
13254
|
+
expires_at: new Date(
|
|
13255
|
+
Date.parse(draft.submitted_at) + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS
|
|
13256
|
+
).toISOString(),
|
|
13257
|
+
receipt_digest: draft.receipt_digest,
|
|
13258
|
+
remote_status: remote.remote_status,
|
|
13259
|
+
processing_state: remote.processing_state
|
|
13260
|
+
}),
|
|
13261
|
+
draft.draft_id
|
|
13262
|
+
);
|
|
13263
|
+
}
|
|
13264
|
+
async function compactLegacySubmittedDraftUnlocked(draft, outbox) {
|
|
13265
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
13266
|
+
remote_status: null,
|
|
13267
|
+
processing_state: null
|
|
13268
|
+
});
|
|
13269
|
+
const path = submittedRecordPath(outbox.directory, draft.draft_id);
|
|
13270
|
+
try {
|
|
13271
|
+
await atomicWritePrivate(path, `${JSON.stringify(completion)}
|
|
13272
|
+
`);
|
|
13273
|
+
} catch (error) {
|
|
13274
|
+
if (!isAlreadyExistsError2(error)) throw error;
|
|
13275
|
+
const existing = parseSubmittedRecord(
|
|
13276
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13277
|
+
draft.draft_id
|
|
13278
|
+
);
|
|
13279
|
+
if (existing.payload_digest !== completion.payload_digest || existing.receipt_digest !== completion.receipt_digest) {
|
|
13280
|
+
throw new Error("Private lesson submitted record conflicts.");
|
|
13281
|
+
}
|
|
13282
|
+
}
|
|
13283
|
+
await unlink(draftPath(outbox.directory, draft.draft_id));
|
|
13284
|
+
await syncDirectory(outbox.directory);
|
|
13285
|
+
}
|
|
13286
|
+
async function reconcileSubmittedRecordsUnlocked(directory, now) {
|
|
13287
|
+
const entries = await secureOutboxEntries(directory);
|
|
13288
|
+
let changed = false;
|
|
13289
|
+
for (const name of entries.filter(
|
|
13290
|
+
(entry) => entry.endsWith(".submitted.json")
|
|
13291
|
+
)) {
|
|
13292
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
13293
|
+
const path = submittedRecordPath(directory, draftId);
|
|
13294
|
+
const completion = parseSubmittedRecord(
|
|
13295
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13296
|
+
draftId
|
|
13297
|
+
);
|
|
13298
|
+
const retainedDraft = draftPath(directory, draftId);
|
|
13299
|
+
if (existsSync4(retainedDraft)) {
|
|
13300
|
+
await assertRegularPrivateFile(
|
|
13301
|
+
retainedDraft,
|
|
13302
|
+
PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13303
|
+
);
|
|
13304
|
+
await unlink(retainedDraft);
|
|
13305
|
+
changed = true;
|
|
13306
|
+
}
|
|
13307
|
+
if (Date.parse(completion.expires_at) <= now.getTime()) {
|
|
13308
|
+
await unlink(path);
|
|
13309
|
+
changed = true;
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
if (changed) await syncDirectory(directory);
|
|
13313
|
+
}
|
|
13314
|
+
function submittedRecordListItem(completion) {
|
|
13315
|
+
return {
|
|
13316
|
+
draft_id: completion.draft_id,
|
|
13317
|
+
state: "submitted",
|
|
13318
|
+
payload_digest: completion.payload_digest,
|
|
13319
|
+
attempts: completion.attempts,
|
|
13320
|
+
next_retry_at: null,
|
|
13321
|
+
last_error_code: null,
|
|
13322
|
+
created_at: completion.created_at,
|
|
13323
|
+
updated_at: completion.updated_at,
|
|
13324
|
+
submitted_at: completion.submitted_at,
|
|
13325
|
+
completion_expires_at: completion.expires_at,
|
|
13326
|
+
terminal_reason: null,
|
|
13327
|
+
hold_telemetry_status: "not_required",
|
|
13328
|
+
hold_event_digest: null,
|
|
13329
|
+
hold_reported_at: null
|
|
13330
|
+
};
|
|
13331
|
+
}
|
|
13332
|
+
function parseSubmittedRecord(serialized, expectedDraftId) {
|
|
13333
|
+
const value = JSON.parse(serialized);
|
|
13334
|
+
const allowedKeys = /* @__PURE__ */ new Set([
|
|
13335
|
+
"version",
|
|
13336
|
+
"draft_id",
|
|
13337
|
+
"state",
|
|
13338
|
+
"payload_digest",
|
|
13339
|
+
"redaction_version",
|
|
13340
|
+
"redactor_digest",
|
|
13341
|
+
"attempts",
|
|
13342
|
+
"created_at",
|
|
13343
|
+
"updated_at",
|
|
13344
|
+
"submitted_at",
|
|
13345
|
+
"expires_at",
|
|
13346
|
+
"receipt_digest",
|
|
13347
|
+
"remote_status",
|
|
13348
|
+
"processing_state"
|
|
13349
|
+
]);
|
|
13350
|
+
if (!isRecord4(value) || Object.keys(value).some((key) => !allowedKeys.has(key)) || value.version !== "private-lesson-outbox-submitted-v1" || value.draft_id !== expectedDraftId || value.state !== "submitted" || !isSha256Digest(value.payload_digest) || typeof value.redaction_version !== "string" || !isSha256Digest(value.redactor_digest) || !Number.isInteger(value.attempts) || Number(value.attempts) < 1 || Number(value.attempts) > 1e3 || !isIsoTimestamp(value.created_at) || !isIsoTimestamp(value.updated_at) || !isIsoTimestamp(value.submitted_at) || !isIsoTimestamp(value.expires_at) || !isSha256Digest(value.receipt_digest) || value.remote_status !== null && value.remote_status !== "accepted_private_candidate" && value.remote_status !== "duplicate" || value.processing_state !== null && value.processing_state !== "queued_for_verification" && value.processing_state !== "already_queued") {
|
|
13351
|
+
throw new Error("Private lesson submitted record is invalid.");
|
|
13352
|
+
}
|
|
13353
|
+
const submittedAt = Date.parse(value.submitted_at);
|
|
13354
|
+
if (Date.parse(value.created_at) > submittedAt || Date.parse(value.updated_at) < submittedAt || Date.parse(value.expires_at) !== submittedAt + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS || value.remote_status === null !== (value.processing_state === null) || value.remote_status === "accepted_private_candidate" && value.processing_state !== "queued_for_verification" || value.remote_status === "duplicate" && value.processing_state !== "already_queued") {
|
|
13355
|
+
throw new Error("Private lesson submitted record is inconsistent.");
|
|
13356
|
+
}
|
|
13357
|
+
return value;
|
|
13358
|
+
}
|
|
13359
|
+
function isSha256Digest(value) {
|
|
13360
|
+
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
13361
|
+
}
|
|
13362
|
+
function isIsoTimestamp(value) {
|
|
13363
|
+
if (typeof value !== "string") return false;
|
|
13364
|
+
const parsed = Date.parse(value);
|
|
13365
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
13366
|
+
}
|
|
12950
13367
|
function isHoldTelemetryStatus(value) {
|
|
12951
13368
|
return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
|
|
12952
13369
|
}
|
|
@@ -12994,7 +13411,7 @@ async function atomicReplacePrivate(path, data) {
|
|
|
12994
13411
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12995
13412
|
const temporary = join4(
|
|
12996
13413
|
dirname2(path),
|
|
12997
|
-
`.${
|
|
13414
|
+
`.${randomBytes3(12).toString("hex")}.tmp`
|
|
12998
13415
|
);
|
|
12999
13416
|
try {
|
|
13000
13417
|
await atomicWritePrivate(temporary, data);
|
|
@@ -13056,6 +13473,10 @@ function draftPath(directory, draftId) {
|
|
|
13056
13473
|
assertDraftId(draftId);
|
|
13057
13474
|
return join4(directory, `${draftId}.lesson.json`);
|
|
13058
13475
|
}
|
|
13476
|
+
function submittedRecordPath(directory, draftId) {
|
|
13477
|
+
assertDraftId(draftId);
|
|
13478
|
+
return join4(directory, `${draftId}.submitted.json`);
|
|
13479
|
+
}
|
|
13059
13480
|
function tombstonePath(directory, draftId) {
|
|
13060
13481
|
assertDraftId(draftId);
|
|
13061
13482
|
return join4(directory, `${draftId}.deleted.json`);
|
|
@@ -13108,7 +13529,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
|
|
|
13108
13529
|
// src/server.ts
|
|
13109
13530
|
var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
13110
13531
|
var DOCTOR_PROBE_TIMEOUT_MS = 7500;
|
|
13111
|
-
var SERVER_VERSION = true ? "0.1.
|
|
13532
|
+
var SERVER_VERSION = true ? "0.1.74" : "0.0.0-dev";
|
|
13112
13533
|
var tools = toolDefinitions;
|
|
13113
13534
|
var doctorCliRequested = process.argv[2] === "doctor";
|
|
13114
13535
|
var inputBuffer = Buffer.alloc(0);
|
|
@@ -13576,6 +13997,13 @@ async function preparePrivateLessonDraft(input) {
|
|
|
13576
13997
|
async function inspectPrivateLessonOutbox(input) {
|
|
13577
13998
|
const summary = await privateLessonOutboxSummary();
|
|
13578
13999
|
if (input.draft_id) {
|
|
14000
|
+
const completion = await readPrivateLessonSubmittedRecord(input.draft_id);
|
|
14001
|
+
if (completion) {
|
|
14002
|
+
return {
|
|
14003
|
+
draft: privateLessonSubmittedActionResult(completion),
|
|
14004
|
+
outbox: publicOutboxSummary(summary)
|
|
14005
|
+
};
|
|
14006
|
+
}
|
|
13579
14007
|
const draft = await readPrivateLessonDraft(input.draft_id);
|
|
13580
14008
|
return {
|
|
13581
14009
|
draft: privateLessonDraftActionResult(draft),
|
|
@@ -13588,6 +14016,12 @@ async function inspectPrivateLessonOutbox(input) {
|
|
|
13588
14016
|
};
|
|
13589
14017
|
}
|
|
13590
14018
|
async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
14019
|
+
const completion = await readPrivateLessonSubmittedRecord(draftId);
|
|
14020
|
+
if (completion) {
|
|
14021
|
+
return privateLessonSubmittedActionResult(completion, {
|
|
14022
|
+
status: "already_submitted"
|
|
14023
|
+
});
|
|
14024
|
+
}
|
|
13591
14025
|
let draft = await readPrivateLessonDraft(draftId);
|
|
13592
14026
|
if (draft.state === "superseded_redactor") {
|
|
13593
14027
|
return privateLessonDraftActionResult(draft, {
|
|
@@ -13596,7 +14030,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13596
14030
|
});
|
|
13597
14031
|
}
|
|
13598
14032
|
if (draft.state === "submitted") {
|
|
13599
|
-
|
|
14033
|
+
if (!draft.receipt_digest) {
|
|
14034
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14035
|
+
"Submitted private lesson draft is missing its receipt digest."
|
|
14036
|
+
);
|
|
14037
|
+
}
|
|
14038
|
+
const migrated = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14039
|
+
receipt_digest: draft.receipt_digest,
|
|
14040
|
+
remote_status: null,
|
|
14041
|
+
processing_state: null,
|
|
14042
|
+
submitted_at: draft.submitted_at ? new Date(draft.submitted_at) : void 0
|
|
14043
|
+
});
|
|
14044
|
+
return privateLessonSubmittedActionResult(migrated, {
|
|
13600
14045
|
status: "already_submitted"
|
|
13601
14046
|
});
|
|
13602
14047
|
}
|
|
@@ -13758,23 +14203,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13758
14203
|
);
|
|
13759
14204
|
}
|
|
13760
14205
|
const receiptDigest = hashValue(canonicalJson(receipt));
|
|
13761
|
-
|
|
13762
|
-
|
|
14206
|
+
const completionProcessingState = processingState === "queued_for_verification" || processingState === "already_queued" ? processingState : null;
|
|
14207
|
+
if (!completionProcessingState) {
|
|
14208
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14209
|
+
"Private lesson submission omitted its processing state."
|
|
14210
|
+
);
|
|
14211
|
+
}
|
|
14212
|
+
const completion2 = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14213
|
+
receipt_digest: receiptDigest,
|
|
14214
|
+
remote_status: status,
|
|
14215
|
+
processing_state: completionProcessingState
|
|
14216
|
+
});
|
|
14217
|
+
draft = {
|
|
14218
|
+
...draft,
|
|
13763
14219
|
state: "submitted",
|
|
13764
|
-
attempts:
|
|
14220
|
+
attempts: completion2.attempts,
|
|
13765
14221
|
next_retry_at: null,
|
|
13766
14222
|
last_error_code: null,
|
|
13767
|
-
submitted_at:
|
|
13768
|
-
receipt_digest:
|
|
13769
|
-
policy: receiptPolicy
|
|
13770
|
-
|
|
14223
|
+
submitted_at: completion2.submitted_at,
|
|
14224
|
+
receipt_digest: completion2.receipt_digest,
|
|
14225
|
+
policy: receiptPolicy,
|
|
14226
|
+
updated_at: completion2.updated_at
|
|
14227
|
+
};
|
|
13771
14228
|
return {
|
|
13772
|
-
...privateLessonDraftActionResult(draft, {
|
|
14229
|
+
...privateLessonDraftActionResult(draft, {
|
|
14230
|
+
status,
|
|
14231
|
+
completion_expires_at: completion2.expires_at
|
|
14232
|
+
}),
|
|
13773
14233
|
receipt,
|
|
13774
14234
|
processing_state: processingState
|
|
13775
14235
|
};
|
|
13776
14236
|
} catch (error) {
|
|
13777
14237
|
draft = await persistPrivateLessonFailure(draft, error);
|
|
14238
|
+
if (draft.state === "submitted") {
|
|
14239
|
+
return privateLessonDraftActionResult(draft, {
|
|
14240
|
+
status: "already_submitted"
|
|
14241
|
+
});
|
|
14242
|
+
}
|
|
13778
14243
|
return privateLessonDraftActionResult(draft, {
|
|
13779
14244
|
status: "not_submitted",
|
|
13780
14245
|
action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
|
|
@@ -14152,13 +14617,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
|
|
|
14152
14617
|
return null;
|
|
14153
14618
|
}
|
|
14154
14619
|
async function persistPrivateLessonFailure(draft, error, explicitCode) {
|
|
14155
|
-
|
|
14156
|
-
|
|
14157
|
-
|
|
14158
|
-
|
|
14159
|
-
|
|
14620
|
+
const completed = await readPrivateLessonSubmittedRecord(draft.draft_id);
|
|
14621
|
+
if (completed) return submittedDraftFromCompletion(draft, completed);
|
|
14622
|
+
try {
|
|
14623
|
+
return await updatePrivateLessonDraft(
|
|
14624
|
+
draft.draft_id,
|
|
14625
|
+
(current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
|
|
14626
|
+
...current,
|
|
14627
|
+
...privateLessonFailureTransition(current, error, explicitCode)
|
|
14628
|
+
}
|
|
14629
|
+
);
|
|
14630
|
+
} catch (updateError) {
|
|
14631
|
+
const racedCompletion = await readPrivateLessonSubmittedRecord(
|
|
14632
|
+
draft.draft_id
|
|
14633
|
+
);
|
|
14634
|
+
if (racedCompletion) {
|
|
14635
|
+
return submittedDraftFromCompletion(draft, racedCompletion);
|
|
14160
14636
|
}
|
|
14161
|
-
|
|
14637
|
+
throw updateError;
|
|
14638
|
+
}
|
|
14639
|
+
}
|
|
14640
|
+
function submittedDraftFromCompletion(draft, completion) {
|
|
14641
|
+
return {
|
|
14642
|
+
...draft,
|
|
14643
|
+
state: "submitted",
|
|
14644
|
+
attempts: completion.attempts,
|
|
14645
|
+
next_retry_at: null,
|
|
14646
|
+
last_error_code: null,
|
|
14647
|
+
submitted_at: completion.submitted_at,
|
|
14648
|
+
receipt_digest: completion.receipt_digest,
|
|
14649
|
+
updated_at: completion.updated_at
|
|
14650
|
+
};
|
|
14162
14651
|
}
|
|
14163
14652
|
async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
|
|
14164
14653
|
return updatePrivateLessonDraft(draft.draft_id, (current) => {
|
|
@@ -14250,6 +14739,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
|
|
|
14250
14739
|
...extra
|
|
14251
14740
|
};
|
|
14252
14741
|
}
|
|
14742
|
+
function privateLessonSubmittedActionResult(completion, extra = {}) {
|
|
14743
|
+
return {
|
|
14744
|
+
draft_id: completion.draft_id,
|
|
14745
|
+
state: "submitted",
|
|
14746
|
+
payload_digest: completion.payload_digest,
|
|
14747
|
+
redaction_version: completion.redaction_version,
|
|
14748
|
+
redactor_digest: completion.redactor_digest,
|
|
14749
|
+
redaction_counts: {},
|
|
14750
|
+
held_reasons: [],
|
|
14751
|
+
attempts: completion.attempts,
|
|
14752
|
+
next_retry_at: null,
|
|
14753
|
+
last_error_code: null,
|
|
14754
|
+
terminal_reason: null,
|
|
14755
|
+
hold_telemetry_status: "not_required",
|
|
14756
|
+
hold_event_digest: null,
|
|
14757
|
+
hold_receipt_digest: null,
|
|
14758
|
+
hold_reported_at: null,
|
|
14759
|
+
submitted_at: completion.submitted_at,
|
|
14760
|
+
completion_expires_at: completion.expires_at,
|
|
14761
|
+
retained_locally: false,
|
|
14762
|
+
...extra
|
|
14763
|
+
};
|
|
14764
|
+
}
|
|
14253
14765
|
function publicOutboxSummary(summary) {
|
|
14254
14766
|
const { directory: _directory, ...safe } = summary;
|
|
14255
14767
|
return safe;
|
|
@@ -14792,6 +15304,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
|
|
|
14792
15304
|
policyResult,
|
|
14793
15305
|
outboxResult
|
|
14794
15306
|
});
|
|
15307
|
+
if (execution === "mcp_tool") {
|
|
15308
|
+
recordConnectionDoctorLifecycleProbe();
|
|
15309
|
+
}
|
|
14795
15310
|
return {
|
|
14796
15311
|
...report,
|
|
14797
15312
|
status: report.status === "healthy" && privateLessonAutomation.status !== "ready" && privateLessonAutomation.status !== "not_applicable" ? "attention" : report.status,
|
|
@@ -22,8 +22,8 @@ background and register a privacy-bounded runtime profile. They never transmit
|
|
|
22
22
|
the hostname, username, config path, or repository path. Follow the returned
|
|
23
23
|
machine-readable `next_action` rather than asking the user to invent an
|
|
24
24
|
identity. Tool-observer and completion timestamps appear after those events
|
|
25
|
-
become eligible
|
|
26
|
-
|
|
25
|
+
become eligible. A fresh doctor reports them as informational; only a later
|
|
26
|
+
missed lifecycle opportunity raises an attention warning.
|
|
27
27
|
|
|
28
28
|
If this skill is visible but `run_connection_doctor` is absent, explicitly tell
|
|
29
29
|
the user that Remembrance is only partially active. Do not silently fall back
|
|
@@ -176,8 +176,10 @@ nothing was sent and the encrypted draft remains local in
|
|
|
176
176
|
`awaiting_authorization`; do not retry it through REST or another transport.
|
|
177
177
|
Transient timeouts, 429s, and 5xx responses remain queued for bounded retry on a
|
|
178
178
|
later lifecycle. Authentication, policy, or validation failures remain held.
|
|
179
|
-
|
|
180
|
-
confirmed local action.
|
|
179
|
+
Unresolved and terminal lessons never expire or auto-delete; deleting one
|
|
180
|
+
requires an explicit confirmed local action. After a signed submission receipt
|
|
181
|
+
is verified, the encrypted lesson content is removed immediately and its
|
|
182
|
+
content-free completion marker is automatically deleted after 14 days.
|
|
181
183
|
|
|
182
184
|
The signed policy pins `private-lesson-redaction-v2` and the exact digest of its
|
|
183
185
|
current rules. An unsupported version or digest moves the encrypted draft to
|
|
@@ -292,8 +292,11 @@ draft remains on the device in `awaiting_authorization`; do not retry it
|
|
|
292
292
|
through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
|
|
293
293
|
responses retry with bounded backoff during later plugin lifecycles. A 401,
|
|
294
294
|
403, policy change, or validation failure remains held for explicit repair.
|
|
295
|
-
|
|
296
|
-
with the local private-lesson tools; deletion requires
|
|
295
|
+
Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
|
|
296
|
+
explicitly delete one with the local private-lesson tools; deletion requires
|
|
297
|
+
confirmation. After a signed submission receipt is verified, the encrypted
|
|
298
|
+
lesson content is removed immediately and its content-free completion marker
|
|
299
|
+
is automatically deleted after 14 days.
|
|
297
300
|
|
|
298
301
|
The signed organization policy pins `private-lesson-redaction-v2` and its exact
|
|
299
302
|
supported redactor digest. If either is unsupported, the finalized
|