@remembrance-ai/opencode-plugin 0.1.73 → 0.1.76
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 +12 -3
- package/opencode.json +2 -2
- package/package.json +1 -1
- package/scripts/hook-core.mjs +707 -41
- package/servers/remembrance-mcp.mjs +646 -60
- package/skills/remembrancer/SKILL.md +32 -12
- package/skills/remembrancer/references/remembrance-setup.md +11 -4
- package/src/index.mjs +50 -11
|
@@ -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
|
}
|
|
@@ -5555,9 +5570,9 @@ function builtInPreferenceDefinition(key) {
|
|
|
5555
5570
|
|
|
5556
5571
|
// ../core/src/agent-guidance.ts
|
|
5557
5572
|
var REMEMBRANCE_QUERY_TOOL_DESCRIPTION = "Call before non-trivial service, API, tool, library, workflow, UI, review, test, security, or deployment work to find relevant Remembrance skills and resources. For short context-dependent follow-ups, infer the concrete task from the full conversation, preserve any plugin-supplied client_context.directive_id/runtime/trigger_reason, and query anyway. Compare each candidate's bounded why_matched evidence and applicability conditions before opening it; discard stated unlikely or corner-case mismatches. High matches should be fetched before custom work; possible and exploratory matches remain optional. Do not use for broad web search or one-off facts.";
|
|
5558
|
-
var REMEMBRANCE_CONNECTION_STATUS_TOOL_DESCRIPTION = "Inspect the active Remembrance transport, credential source,
|
|
5573
|
+
var REMEMBRANCE_CONNECTION_STATUS_TOOL_DESCRIPTION = "Inspect the active Remembrance transport, credential source, authenticated registry scope, and content-free recent contribution outcomes for the current installation or API key without exposing credentials or lesson content. Call this before diagnosing authentication, checking environment variables, or making anonymous REST/browser probes. Local or bundled MCP can read REMEMBRANCE_API_KEY or ~/.config/remembrance/config.json; hosted MCP cannot read local files and authenticates from its request header. When a declared native lifecycle is degraded, local MCP may submit bounded, content-free health evidence unless REMEMBRANCE_HEALTH_REPORTING=0. Never infer the plugin's scope from REMEMBRANCE_API_KEY alone.";
|
|
5559
5574
|
var REMEMBRANCE_CONNECTION_DOCTOR_TOOL_DESCRIPTION = "Run a bounded, non-mutating Remembrance connection diagnostic. It verifies the active transport, a redacted registry destination, authenticated scope, query authorization, a real catalog read, and organization submission capability. Local MCP also checks shared-config permissions, destination consistency, native plugin lifecycle, and signing identity; hosted MCP marks local-only checks unavailable. It never submits content, creates query demand, opens a review item, or returns credentials, absolute paths, private registry URLs, prompts, or repository data.";
|
|
5560
|
-
var REMEMBRANCE_MCP_SERVER_INSTRUCTIONS = "Remembrance is shared operational memory for agents. Before claiming that Remembrance is anonymous, unconfigured, partially active, or missing an organization key, call run_connection_doctor for the transport you will actually use. It performs a safe catalog read and returns exact bounded remediation without submitting content. Use get_connection_status only when you need the underlying transport and lifecycle fields. An unset REMEMBRANCE_API_KEY does not prove that a local plugin is anonymous because local hooks and bundled MCP may read ~/.config/remembrance/config.json. Conversely, hosted MCP cannot read that local file. Anonymous curl or browser probes describe only those requests, not plugin authentication. Never print or request the raw key. When the user explicitly names an authorized Remembrance skill or supplies a remembrance://skills/{slug} URI, resolve ambiguous names with list_skills using its normalized slug-prefix filter, then call invoke_skill with an exact returned slug. Never guess a slug. list_skills is catalog resolution, not relevance search; use query_skills when the user wants relevant candidates rather than a known selection. invoke_skill rechecks authorization and organization policy, resolves the current reviewed version, and returns the only full instruction body. Catalog listings and MCP resource reads are lightweight handles, not skill use. Do not run query_skills merely to rediscover that explicit selection, and do not submit query-fit feedback for it. After meaningful direct use, follow the returned task-outcome and post-use feedback instructions once. BEFORE non-trivial service/API/tool/library/workflow/UI/review/test/security/deployment work, call query_skills to reuse proven skills and resources instead of rediscovering them. A local repository change still qualifies when it involves a reusable workflow or lesson; skip only genuinely trivial throwaway edits and one-off facts. For short follow-ups such as 'fix these issues', 'continue', or 'try again', infer the concrete task, domain, and constraints from the full conversation and still call query_skills; do not wait for the current prompt to repeat trigger keywords. Treat match_tier as a decision aid, then inspect why_matched and applicability before opening a result. These fields show bounded matched terms/capabilities, satisfied and missed constraints, qualitative lexical/semantic evidence, declared scope, use conditions, and exclusions without exposing unstable raw ranking scores. Discard an unlikely or irrelevant corner-case result and report fit: poor; do not force its use. For a remaining high match, call get_skill or get_resource before custom work and pass the returned query_id/result_id so the surfaced-to-fetch funnel closes; possible and exploratory matches are optional. The response also includes approximate context tokens, verified uses, risk, tags, permissions, dependencies, and contraindications so you can weigh the detour safely. Honor query_skills.skill_access on every response. When policy is org_only, use only returned organization skills and never substitute bundled or live public skill references; if an organization key is configured and the query is unavailable, fail closed because the policy cannot be confirmed. AFTER query_skills, call submit_query_feedback once with one complete set of explicit good/partial/poor judgments using the returned query_id and result_id values; leave unrated results neutral, and remember that a poor query match is not the same as a globally bad skill. Reuse the same organization or anonymous auth scope as the query. AFTER you actually use a skill or resource, close the post-use loop with submit_feedback (useful true/false plus a one-line lesson and the originating query_id/result_id; it returns a ready submit_remembrance payload), then submit_remembrance if the lesson is reusable. If nothing fit and you built a new workflow, use propose_private_skill for an organization-private candidate. With an organization key, propose_skill_idea also remains organization-private. Never remove, suppress, or bypass that key to force a public submission; submit privately, then use the reviewed public-propagation flow when the organization chooses to share it. When delegating, pass the selected slug, exact version, query_id, and result_id to the subagent; it should invoke/fetch that result or run its own full-context query. The parent reports the terminal outcome unless the subagent creates its own invocation. Before finishing any reusable task, self-check for a missed query. If you catch your own mistake, the user catches one, CI/deploy fails, a security issue appears, or you fix a release/versioning miss, submit a failure_report remembrance even if no skill was used; raw MCP clients have no plugin Stop hook to remind you later. A host privacy or tenant-policy denial happens before Remembrance receives the request. Tell the user: 'Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.' Do not retry the blocked content through another transport or claim submission. Attach evidence (reproduction detail, artifact hashes, or an attestation); evidence-less public reports wait in unverified intake until corroborated. Redact secrets, private URLs, credentials, raw logs, and proprietary content; submit summaries and hashes, not raw traces.";
|
|
5575
|
+
var REMEMBRANCE_MCP_SERVER_INSTRUCTIONS = "Remembrance is shared operational memory for agents. Before claiming that Remembrance is anonymous, unconfigured, partially active, or missing an organization key, call run_connection_doctor for the transport you will actually use. It performs a safe catalog read and returns exact bounded remediation without submitting content. Use get_connection_status only when you need the underlying transport and lifecycle fields. An unset REMEMBRANCE_API_KEY does not prove that a local plugin is anonymous because local hooks and bundled MCP may read ~/.config/remembrance/config.json. Conversely, hosted MCP cannot read that local file. Anonymous curl or browser probes describe only those requests, not plugin authentication. Never print or request the raw key. When the user explicitly names an authorized Remembrance skill or supplies a remembrance://skills/{slug} URI, resolve ambiguous names with list_skills using its normalized slug-prefix filter, then call invoke_skill with an exact returned slug. Never guess a slug. list_skills is catalog resolution, not relevance search; use query_skills when the user wants relevant candidates rather than a known selection. invoke_skill rechecks authorization and organization policy, resolves the current reviewed version, and returns the only full instruction body. Catalog listings and MCP resource reads are lightweight handles, not skill use. Do not run query_skills merely to rediscover that explicit selection, and do not submit query-fit feedback for it. After meaningful direct use, follow the returned task-outcome and post-use feedback instructions once. BEFORE non-trivial service/API/tool/library/workflow/UI/review/test/security/deployment work, call query_skills to reuse proven skills and resources instead of rediscovering them. A local repository change still qualifies when it involves a reusable workflow or lesson; skip only genuinely trivial throwaway edits and one-off facts. For short follow-ups such as 'fix these issues', 'continue', or 'try again', infer the concrete task, domain, and constraints from the full conversation and still call query_skills; do not wait for the current prompt to repeat trigger keywords. Treat match_tier as a decision aid, then inspect why_matched and applicability before opening a result. These fields show bounded matched terms/capabilities, satisfied and missed constraints, qualitative lexical/semantic evidence, declared scope, use conditions, and exclusions without exposing unstable raw ranking scores. Discard an unlikely or irrelevant corner-case result and report fit: poor; do not force its use. For a remaining high match, call get_skill or get_resource before custom work and pass the returned query_id/result_id so the surfaced-to-fetch funnel closes; possible and exploratory matches are optional. The response also includes approximate context tokens, verified uses, risk, tags, permissions, dependencies, and contraindications so you can weigh the detour safely. Honor query_skills.skill_access on every response. When policy is org_only, use only returned organization skills and never substitute bundled or live public skill references; if an organization key is configured and the query is unavailable, fail closed because the policy cannot be confirmed. AFTER query_skills, call submit_query_feedback once with one complete set of explicit good/partial/poor judgments using the returned query_id and result_id values; leave unrated results neutral, and remember that a poor query match is not the same as a globally bad skill. Reuse the same organization or anonymous auth scope as the query. AFTER you actually use a skill or resource, close the post-use loop with submit_feedback (useful true/false plus a one-line lesson and the originating query_id/result_id; it returns a ready submit_remembrance payload), then submit_remembrance if the lesson is reusable. If nothing fit and you built a new workflow, use propose_private_skill for an organization-private candidate. With an organization key, propose_skill_idea also remains organization-private. Never remove, suppress, or bypass that key to force a public submission; submit privately, then use the reviewed public-propagation flow when the organization chooses to share it. When delegating, pass the selected slug, exact version, query_id, and result_id to the subagent; it should invoke/fetch that result or run its own full-context query. The parent reports the terminal outcome unless the subagent creates its own invocation. Route memory deliberately: host-local memory is for facts about this human, repository, or machine; Remembrance is for generalized lessons another approved organization agent would otherwise rediscover. A local-memory write does not satisfy organization-shared capture. Before finishing any reusable task, self-check for a missed query. If you catch your own mistake, the user catches one, CI/deploy fails, a security issue appears, or you fix a release/versioning miss, submit a failure_report remembrance even if no skill was used; raw MCP clients have no plugin Stop hook to remind you later. A host privacy or tenant-policy denial happens before Remembrance receives the request. Tell the user: 'Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.' Do not retry the blocked content through another transport or claim submission. Attach evidence (reproduction detail, artifact hashes, or an attestation); evidence-less public reports wait in unverified intake until corroborated. Redact secrets, private URLs, credentials, raw logs, and proprietary content; submit summaries and hashes, not raw traces.";
|
|
5561
5576
|
|
|
5562
5577
|
// ../core/src/redaction.ts
|
|
5563
5578
|
var SECRET_PATTERN_SPECS = [
|
|
@@ -5716,6 +5731,20 @@ var PRIVATE_LESSON_TAG_IDENTITY_HINTS = /* @__PURE__ */ new Set([
|
|
|
5716
5731
|
"tenant",
|
|
5717
5732
|
"user"
|
|
5718
5733
|
]);
|
|
5734
|
+
var PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES = /* @__PURE__ */ new Set([
|
|
5735
|
+
"corp",
|
|
5736
|
+
"org",
|
|
5737
|
+
"organization"
|
|
5738
|
+
]);
|
|
5739
|
+
var PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES = /* @__PURE__ */ new Set([
|
|
5740
|
+
"cross",
|
|
5741
|
+
"inter",
|
|
5742
|
+
"intra",
|
|
5743
|
+
"multi",
|
|
5744
|
+
"per",
|
|
5745
|
+
"single",
|
|
5746
|
+
"sub"
|
|
5747
|
+
]);
|
|
5719
5748
|
var privateLessonCanonicalTagSchema = privateLessonTagSyntaxSchema.superRefine((tag, ctx) => {
|
|
5720
5749
|
const inspected = inspectPrivateLessonTag(tag);
|
|
5721
5750
|
if (inspected.tag !== tag || inspected.held_reasons.length > 0) {
|
|
@@ -6110,8 +6139,14 @@ function computePrivateLessonRedactorDigest() {
|
|
|
6110
6139
|
metadata: {
|
|
6111
6140
|
correlation_ids: "domain-separated-sha256-v1",
|
|
6112
6141
|
tag_syntax: PRIVATE_LESSON_TAG_PATTERN.source,
|
|
6113
|
-
tag_safety: "redaction-plus-identity-hints-
|
|
6142
|
+
tag_safety: "redaction-plus-identity-hints-v2",
|
|
6114
6143
|
tag_identity_hints: [...PRIVATE_LESSON_TAG_IDENTITY_HINTS].sort(),
|
|
6144
|
+
tag_identity_label_suffixes: [
|
|
6145
|
+
...PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES
|
|
6146
|
+
].sort(),
|
|
6147
|
+
tag_relational_prefixes: [
|
|
6148
|
+
...PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES
|
|
6149
|
+
].sort(),
|
|
6115
6150
|
tag_aliases: PRIVATE_LESSON_SAFE_TAG_ALIASES
|
|
6116
6151
|
},
|
|
6117
6152
|
rules: REPLACEMENTS.map(({ category, pattern, hold }) => ({
|
|
@@ -6135,7 +6170,10 @@ function inspectPrivateLessonTag(value) {
|
|
|
6135
6170
|
const identityHints = new Set(
|
|
6136
6171
|
aliased.split(/[-_]/u).filter((segment) => PRIVATE_LESSON_TAG_IDENTITY_HINTS.has(segment))
|
|
6137
6172
|
);
|
|
6138
|
-
|
|
6173
|
+
const segments = aliased.split(/[-_]/u);
|
|
6174
|
+
const isGenericOrganizationRelationship = segments.length === 2 && PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES.has(segments[0] ?? "") && PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES.has(segments[1] ?? "");
|
|
6175
|
+
const hasLikelyIdentityLabel = segments.length >= 2 && !isGenericOrganizationRelationship && PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES.has(segments.at(-1) ?? "") && segments.slice(0, -1).some((segment) => !PRIVATE_LESSON_TAG_IDENTITY_HINTS.has(segment));
|
|
6176
|
+
if (!privateLessonTagSyntaxSchema.safeParse(aliased).success || redacted.text !== aliased || identityHints.size >= 2 || hasLikelyIdentityLabel) {
|
|
6139
6177
|
if (held.size === 0) {
|
|
6140
6178
|
counts.identifier = Math.min(999, (counts.identifier ?? 0) + 1);
|
|
6141
6179
|
held.add("identifier");
|
|
@@ -6162,12 +6200,18 @@ function pseudonymizeCorrelationId(value) {
|
|
|
6162
6200
|
return `corr_${digest.slice(0, 48)}`;
|
|
6163
6201
|
}
|
|
6164
6202
|
var CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST = computePrivateLessonRedactorDigest();
|
|
6203
|
+
var PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST = "sha256:bca83ac50b0124a0401f806b3498bbb1ec977c02c1975d5345cb8d0b85c00b75";
|
|
6165
6204
|
var PRIVATE_LESSON_SUPPORTED_REDACTOR_PROFILES = Object.freeze({
|
|
6166
6205
|
[PRIVATE_LESSON_REDACTION_VERSION]: Object.freeze({
|
|
6167
6206
|
[CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST]: Object.freeze({
|
|
6168
6207
|
version: PRIVATE_LESSON_REDACTION_VERSION,
|
|
6169
6208
|
digest: CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST,
|
|
6170
6209
|
normalization: "unicode-nfc-lf-space-v1"
|
|
6210
|
+
}),
|
|
6211
|
+
[PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST]: Object.freeze({
|
|
6212
|
+
version: PRIVATE_LESSON_REDACTION_VERSION,
|
|
6213
|
+
digest: PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST,
|
|
6214
|
+
normalization: "unicode-nfc-lf-space-v1"
|
|
6171
6215
|
})
|
|
6172
6216
|
})
|
|
6173
6217
|
});
|
|
@@ -7595,7 +7639,7 @@ var seedSkills = [
|
|
|
7595
7639
|
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
7640
|
status: "active",
|
|
7597
7641
|
visibility: "public",
|
|
7598
|
-
version: "0.1.
|
|
7642
|
+
version: "0.1.25",
|
|
7599
7643
|
domains: ["agent-skills", "mcp", "resource-discovery"],
|
|
7600
7644
|
tags: [
|
|
7601
7645
|
"remembrance",
|
|
@@ -7646,7 +7690,7 @@ var seedSkills = [
|
|
|
7646
7690
|
"gemini",
|
|
7647
7691
|
"rest"
|
|
7648
7692
|
],
|
|
7649
|
-
version: "0.1.
|
|
7693
|
+
version: "0.1.25",
|
|
7650
7694
|
status: "active",
|
|
7651
7695
|
visibility: "public",
|
|
7652
7696
|
providers: ["codex", "claude", "cursor", "openclaw", "generic"],
|
|
@@ -8001,16 +8045,23 @@ This is the only host-visible network action in the flow. Approve or persist
|
|
|
8001
8045
|
approval for this exact action, not every Remembrance write. The server derives
|
|
8002
8046
|
the organization and private visibility from the authenticated key, verifies a
|
|
8003
8047
|
signed organization policy plus purpose-bound attestation, and returns a signed
|
|
8004
|
-
content-free receipt. The
|
|
8005
|
-
public
|
|
8048
|
+
content-free receipt. The action always submits privately and never falls back
|
|
8049
|
+
to anonymous or public submission. After an accepted private outcome, a
|
|
8050
|
+
separate server-owned process may create a freshly redacted public candidate
|
|
8051
|
+
only when the organization admin enabled the **Contribution propagation**
|
|
8052
|
+
setting; the submitting agent neither chooses nor sees that process, and the
|
|
8053
|
+
derivative must pass the complete public pipeline.
|
|
8006
8054
|
|
|
8007
8055
|
If the host denies the action, no candidate content was sent. The encrypted
|
|
8008
8056
|
draft remains on the device in \`awaiting_authorization\`; do not retry it
|
|
8009
8057
|
through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
|
|
8010
8058
|
responses retry with bounded backoff during later plugin lifecycles. A 401,
|
|
8011
8059
|
403, policy change, or validation failure remains held for explicit repair.
|
|
8012
|
-
|
|
8013
|
-
with the local private-lesson tools; deletion requires
|
|
8060
|
+
Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
|
|
8061
|
+
explicitly delete one with the local private-lesson tools; deletion requires
|
|
8062
|
+
confirmation. After a signed submission receipt is verified, the encrypted
|
|
8063
|
+
lesson content is removed immediately and its content-free completion marker
|
|
8064
|
+
is automatically deleted after 14 days.
|
|
8014
8065
|
|
|
8015
8066
|
The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
|
|
8016
8067
|
supported redactor digest. If either is unsupported, the finalized
|
|
@@ -10841,7 +10892,7 @@ var toolDefinitions = [
|
|
|
10841
10892
|
),
|
|
10842
10893
|
tool(
|
|
10843
10894
|
"submit_private_lesson_candidate",
|
|
10844
|
-
"Submit exactly one canonical, challenge-attested organization-private lesson candidate or one content-free held-safety telemetry event. Local plugin clients normally pass only the draft_id returned by prepare_private_lesson_candidate; raw or hosted clients pass the strict candidate-or-hold union. The action
|
|
10895
|
+
"Submit exactly one canonical, challenge-attested organization-private lesson candidate or one content-free held-safety telemetry event. Local plugin clients normally pass only the draft_id returned by prepare_private_lesson_candidate; raw or hosted clients pass the strict candidate-or-hold union. The action always creates only an organization-private submission and never falls back to anonymous or public submission. Held telemetry contains no lesson prose and can never enter verification, review, topology, or skill materialization. Exact replay is idempotent; host denial leaves the local draft untouched.",
|
|
10845
10896
|
"/api/v1/agent/private-lessons",
|
|
10846
10897
|
privateLessonSubmissionToolSchema
|
|
10847
10898
|
),
|
|
@@ -11053,8 +11104,16 @@ function isAlreadyExistsError(error) {
|
|
|
11053
11104
|
}
|
|
11054
11105
|
|
|
11055
11106
|
// src/connection-status.ts
|
|
11056
|
-
import {
|
|
11057
|
-
|
|
11107
|
+
import {
|
|
11108
|
+
chmodSync,
|
|
11109
|
+
existsSync as existsSync2,
|
|
11110
|
+
lstatSync as lstatSync2,
|
|
11111
|
+
mkdirSync,
|
|
11112
|
+
renameSync,
|
|
11113
|
+
rmSync,
|
|
11114
|
+
writeFileSync
|
|
11115
|
+
} from "node:fs";
|
|
11116
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
11058
11117
|
import { isIP } from "node:net";
|
|
11059
11118
|
import { homedir as homedir3 } from "node:os";
|
|
11060
11119
|
import { join as join3 } from "node:path";
|
|
@@ -11349,6 +11408,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
|
|
|
11349
11408
|
"tool_observer",
|
|
11350
11409
|
"completion_hook"
|
|
11351
11410
|
];
|
|
11411
|
+
var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
|
|
11352
11412
|
var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
11353
11413
|
var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
11354
11414
|
var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -11371,6 +11431,57 @@ var nodeFileSystem2 = {
|
|
|
11371
11431
|
mode: secureLocalFileMode,
|
|
11372
11432
|
list: listSecureLocalDirectory
|
|
11373
11433
|
};
|
|
11434
|
+
function recordConnectionDoctorLifecycleProbe(options = {}) {
|
|
11435
|
+
const env = options.env ?? process.env;
|
|
11436
|
+
const rawSurface = String(env.REMEMBRANCE_PLUGIN_HOST ?? "").trim().toLowerCase();
|
|
11437
|
+
if (!isPluginHostSurface(rawSurface)) return false;
|
|
11438
|
+
const healthDir = env.REMEMBRANCE_PLUGIN_HEALTH_DIR ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR) : join3(
|
|
11439
|
+
options.homeDirectory ?? homedir3(),
|
|
11440
|
+
".cache",
|
|
11441
|
+
"remembrance",
|
|
11442
|
+
"plugin-health"
|
|
11443
|
+
);
|
|
11444
|
+
const path = join3(healthDir, `${rawSurface}.json`);
|
|
11445
|
+
if (!existsSync2(path)) return false;
|
|
11446
|
+
const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
11447
|
+
try {
|
|
11448
|
+
const parsed = JSON.parse(
|
|
11449
|
+
readSecureLocalText(path, {
|
|
11450
|
+
maxBytes: MAX_LOCAL_LIFECYCLE_MARKER_BYTES
|
|
11451
|
+
})
|
|
11452
|
+
);
|
|
11453
|
+
if (!isRecord3(parsed) || parsed.surface !== rawSurface) return false;
|
|
11454
|
+
const components = isRecord3(parsed.components) ? parsed.components : {};
|
|
11455
|
+
if (typeof components.session_start !== "string") return false;
|
|
11456
|
+
mkdirSync(healthDir, { recursive: true, mode: 448 });
|
|
11457
|
+
const directoryMetadata = lstatSync2(healthDir);
|
|
11458
|
+
if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
|
|
11459
|
+
return false;
|
|
11460
|
+
}
|
|
11461
|
+
chmodSync(healthDir, 448);
|
|
11462
|
+
writeFileSync(
|
|
11463
|
+
temporaryPath,
|
|
11464
|
+
`${JSON.stringify({
|
|
11465
|
+
...parsed,
|
|
11466
|
+
components: {
|
|
11467
|
+
...components,
|
|
11468
|
+
[CONNECTION_DOCTOR_PROBE_COMPONENT]: (options.now ?? /* @__PURE__ */ new Date()).toISOString()
|
|
11469
|
+
}
|
|
11470
|
+
})}
|
|
11471
|
+
`,
|
|
11472
|
+
{ mode: 384, flag: "wx" }
|
|
11473
|
+
);
|
|
11474
|
+
renameSync(temporaryPath, path);
|
|
11475
|
+
chmodSync(path, 384);
|
|
11476
|
+
return true;
|
|
11477
|
+
} catch {
|
|
11478
|
+
try {
|
|
11479
|
+
rmSync(temporaryPath, { force: true });
|
|
11480
|
+
} catch {
|
|
11481
|
+
}
|
|
11482
|
+
return false;
|
|
11483
|
+
}
|
|
11484
|
+
}
|
|
11374
11485
|
function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
|
|
11375
11486
|
const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
|
|
11376
11487
|
return join3(configRoot, "remembrance", "config.json");
|
|
@@ -11735,6 +11846,20 @@ function localPluginLifecycleHealth(options) {
|
|
|
11735
11846
|
}
|
|
11736
11847
|
if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
|
|
11737
11848
|
}
|
|
11849
|
+
let latestDoctorProbe;
|
|
11850
|
+
let latestDoctorProbeMs = Number.NEGATIVE_INFINITY;
|
|
11851
|
+
for (const entry of cohort) {
|
|
11852
|
+
const components2 = isRecord3(entry.marker.components) ? entry.marker.components : {};
|
|
11853
|
+
const value = components2[CONNECTION_DOCTOR_PROBE_COMPONENT];
|
|
11854
|
+
const valueMs = validLifecycleTimestamp(value, nowMs);
|
|
11855
|
+
if (valueMs > latestDoctorProbeMs) {
|
|
11856
|
+
latestDoctorProbeMs = valueMs;
|
|
11857
|
+
latestDoctorProbe = value;
|
|
11858
|
+
}
|
|
11859
|
+
}
|
|
11860
|
+
if (Number.isFinite(latestDoctorProbeMs)) {
|
|
11861
|
+
mergedComponents[CONNECTION_DOCTOR_PROBE_COMPONENT] = latestDoctorProbe;
|
|
11862
|
+
}
|
|
11738
11863
|
const marker = {
|
|
11739
11864
|
...newestEntry.marker,
|
|
11740
11865
|
components: mergedComponents,
|
|
@@ -11772,6 +11897,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11772
11897
|
const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
|
|
11773
11898
|
const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
|
|
11774
11899
|
const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
|
|
11900
|
+
const pendingComponents = [];
|
|
11775
11901
|
if (hookTrustReviewEvents.size > 0) {
|
|
11776
11902
|
issues.push({
|
|
11777
11903
|
code: "hook_trust_required",
|
|
@@ -11780,17 +11906,40 @@ function localPluginLifecycleHealth(options) {
|
|
|
11780
11906
|
])
|
|
11781
11907
|
});
|
|
11782
11908
|
}
|
|
11783
|
-
if (coreLifecycleObserved && !
|
|
11784
|
-
|
|
11785
|
-
|
|
11786
|
-
|
|
11787
|
-
|
|
11909
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("PostToolUse")) {
|
|
11910
|
+
const toolObserverMs = validLifecycleTimestamp(
|
|
11911
|
+
componentRecord.tool_observer,
|
|
11912
|
+
nowMs
|
|
11913
|
+
);
|
|
11914
|
+
const missedProbe = Number.isFinite(latestDoctorProbeMs) && (!Number.isFinite(toolObserverMs) || toolObserverMs < latestDoctorProbeMs);
|
|
11915
|
+
if (missedProbe) {
|
|
11916
|
+
issues.push({
|
|
11917
|
+
code: "tool_observer_not_observed",
|
|
11918
|
+
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."
|
|
11919
|
+
});
|
|
11920
|
+
} else if (!components.tool_observer.observed) {
|
|
11921
|
+
pendingComponents.push("tool_observer");
|
|
11922
|
+
}
|
|
11788
11923
|
}
|
|
11789
|
-
if (coreLifecycleObserved && !
|
|
11790
|
-
|
|
11791
|
-
|
|
11792
|
-
|
|
11793
|
-
|
|
11924
|
+
if (coreLifecycleObserved && !hookTrustReviewEvents.has("Stop")) {
|
|
11925
|
+
const promptHookMs = validLifecycleTimestamp(
|
|
11926
|
+
componentRecord.prompt_hook,
|
|
11927
|
+
nowMs
|
|
11928
|
+
);
|
|
11929
|
+
const completionHookMs = validLifecycleTimestamp(
|
|
11930
|
+
componentRecord.completion_hook,
|
|
11931
|
+
nowMs
|
|
11932
|
+
);
|
|
11933
|
+
const completedTurnOpportunity = Number.isFinite(latestDoctorProbeMs) && Number.isFinite(promptHookMs) && promptHookMs > latestDoctorProbeMs;
|
|
11934
|
+
const missedCompletedTurn = completedTurnOpportunity && (!Number.isFinite(completionHookMs) || completionHookMs < latestDoctorProbeMs);
|
|
11935
|
+
if (missedCompletedTurn) {
|
|
11936
|
+
issues.push({
|
|
11937
|
+
code: "completion_hook_not_observed",
|
|
11938
|
+
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."
|
|
11939
|
+
});
|
|
11940
|
+
} else if (!components.completion_hook.observed) {
|
|
11941
|
+
pendingComponents.push("completion_hook");
|
|
11942
|
+
}
|
|
11794
11943
|
}
|
|
11795
11944
|
const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
|
|
11796
11945
|
const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
|
|
@@ -11834,7 +11983,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11834
11983
|
);
|
|
11835
11984
|
return {
|
|
11836
11985
|
expected: true,
|
|
11837
|
-
status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11986
|
+
status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
|
|
11838
11987
|
surface,
|
|
11839
11988
|
plugin_version: markerVersion || null,
|
|
11840
11989
|
host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
|
|
@@ -11847,6 +11996,7 @@ function localPluginLifecycleHealth(options) {
|
|
|
11847
11996
|
},
|
|
11848
11997
|
hook_trust: hookTrust,
|
|
11849
11998
|
components,
|
|
11999
|
+
pending_components: pendingComponents,
|
|
11850
12000
|
issues,
|
|
11851
12001
|
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
12002
|
};
|
|
@@ -12090,7 +12240,7 @@ async function checkClientUpdate(input) {
|
|
|
12090
12240
|
}
|
|
12091
12241
|
|
|
12092
12242
|
// src/private-lesson-outbox.ts
|
|
12093
|
-
import { createCipheriv, createDecipheriv, randomBytes as
|
|
12243
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
|
|
12094
12244
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
12095
12245
|
import {
|
|
12096
12246
|
chmod as chmod2,
|
|
@@ -12106,7 +12256,7 @@ import { homedir as homedir4 } from "node:os";
|
|
|
12106
12256
|
import { dirname as dirname2, join as join4 } from "node:path";
|
|
12107
12257
|
|
|
12108
12258
|
// src/private-lesson-keychain.ts
|
|
12109
|
-
import { randomBytes } from "node:crypto";
|
|
12259
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
12110
12260
|
import { execFileSync } from "node:child_process";
|
|
12111
12261
|
import { existsSync as existsSync3 } from "node:fs";
|
|
12112
12262
|
var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
|
|
@@ -12148,7 +12298,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
|
|
|
12148
12298
|
if (!keychainEnabled(env, dependencies)) return null;
|
|
12149
12299
|
const existing = readPrivateLessonKeychainKey(env, dependencies);
|
|
12150
12300
|
if (existing) return existing;
|
|
12151
|
-
const encoded = dependencies.randomKey ??
|
|
12301
|
+
const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
|
|
12152
12302
|
const generated = decodeKey(encoded);
|
|
12153
12303
|
if (!generated) return null;
|
|
12154
12304
|
try {
|
|
@@ -12184,9 +12334,11 @@ function decodeKey(encoded) {
|
|
|
12184
12334
|
// src/private-lesson-outbox.ts
|
|
12185
12335
|
var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
|
|
12186
12336
|
var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
|
|
12337
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
12187
12338
|
var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
|
|
12188
12339
|
var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
|
|
12189
12340
|
var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
|
|
12341
|
+
var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
|
|
12190
12342
|
var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
|
|
12191
12343
|
var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
|
|
12192
12344
|
var privateLessonOutboxLockContext = new AsyncLocalStorage();
|
|
@@ -12254,6 +12406,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12254
12406
|
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12255
12407
|
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12256
12408
|
}
|
|
12409
|
+
if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
|
|
12410
|
+
throw new Error("Private lesson draft was already submitted.");
|
|
12411
|
+
}
|
|
12257
12412
|
const path = draftPath(outbox.directory, draftId);
|
|
12258
12413
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12259
12414
|
const serialized = await readBoundedFile(
|
|
@@ -12272,10 +12427,77 @@ async function readPrivateLessonDraft(draftId, options = {}) {
|
|
|
12272
12427
|
return reconciled;
|
|
12273
12428
|
});
|
|
12274
12429
|
}
|
|
12430
|
+
async function readPrivateLessonSubmittedRecord(draftId, options = {}) {
|
|
12431
|
+
assertDraftId(draftId);
|
|
12432
|
+
const directory = options.outboxDirectory ?? privateLessonOutboxDirectory(options.env, options.homeDirectory);
|
|
12433
|
+
if (!existsSync4(directory)) return null;
|
|
12434
|
+
assertPrivateLessonPathMetadata(await lstat(directory), "directory");
|
|
12435
|
+
return withPrivateLessonOutboxLock(directory, async () => {
|
|
12436
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12437
|
+
directory,
|
|
12438
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12439
|
+
);
|
|
12440
|
+
if (existsSync4(tombstonePath(directory, draftId))) {
|
|
12441
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12442
|
+
}
|
|
12443
|
+
const path = submittedRecordPath(directory, draftId);
|
|
12444
|
+
if (!existsSync4(path)) return null;
|
|
12445
|
+
await assertRegularPrivateFile(
|
|
12446
|
+
path,
|
|
12447
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12448
|
+
);
|
|
12449
|
+
return parseSubmittedRecord(
|
|
12450
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12451
|
+
draftId
|
|
12452
|
+
);
|
|
12453
|
+
});
|
|
12454
|
+
}
|
|
12455
|
+
async function readPrivateLessonSubmissionSource(draftId, options = {}) {
|
|
12456
|
+
assertDraftId(draftId);
|
|
12457
|
+
const outbox = await readyOutbox(options);
|
|
12458
|
+
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12459
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12460
|
+
outbox.directory,
|
|
12461
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12462
|
+
);
|
|
12463
|
+
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12464
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12465
|
+
}
|
|
12466
|
+
const completionPath = submittedRecordPath(outbox.directory, draftId);
|
|
12467
|
+
if (existsSync4(completionPath)) {
|
|
12468
|
+
await assertRegularPrivateFile(
|
|
12469
|
+
completionPath,
|
|
12470
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12471
|
+
);
|
|
12472
|
+
return {
|
|
12473
|
+
kind: "submitted",
|
|
12474
|
+
completion: parseSubmittedRecord(
|
|
12475
|
+
await readBoundedFile(
|
|
12476
|
+
completionPath,
|
|
12477
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12478
|
+
),
|
|
12479
|
+
draftId
|
|
12480
|
+
)
|
|
12481
|
+
};
|
|
12482
|
+
}
|
|
12483
|
+
return {
|
|
12484
|
+
kind: "draft",
|
|
12485
|
+
draft: await readPrivateLessonDraft(draftId, {
|
|
12486
|
+
...options,
|
|
12487
|
+
outboxDirectory: outbox.directory,
|
|
12488
|
+
encryption: outbox.encryption
|
|
12489
|
+
})
|
|
12490
|
+
};
|
|
12491
|
+
});
|
|
12492
|
+
}
|
|
12275
12493
|
async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
12276
12494
|
const outbox = await readyOutbox(options);
|
|
12277
12495
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12278
12496
|
await reconcileExplicitDeletionTombstones(outbox.directory);
|
|
12497
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12498
|
+
outbox.directory,
|
|
12499
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12500
|
+
);
|
|
12279
12501
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12280
12502
|
let inspected = 0;
|
|
12281
12503
|
let terminalized = 0;
|
|
@@ -12296,10 +12518,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
|
|
|
12296
12518
|
encryption: outbox.encryption
|
|
12297
12519
|
});
|
|
12298
12520
|
inspected += 1;
|
|
12521
|
+
if (draft.state === "submitted") {
|
|
12522
|
+
await compactLegacySubmittedDraftUnlocked(draft, outbox);
|
|
12523
|
+
continue;
|
|
12524
|
+
}
|
|
12299
12525
|
if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
|
|
12300
12526
|
terminalized += 1;
|
|
12301
12527
|
}
|
|
12302
12528
|
}
|
|
12529
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12530
|
+
outbox.directory,
|
|
12531
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12532
|
+
);
|
|
12303
12533
|
return { inspected, terminalized };
|
|
12304
12534
|
});
|
|
12305
12535
|
}
|
|
@@ -12313,8 +12543,22 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12313
12543
|
const entries = await secureOutboxEntries(outbox.directory);
|
|
12314
12544
|
const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
|
|
12315
12545
|
const drafts = [];
|
|
12316
|
-
for (const name of entries.filter(
|
|
12546
|
+
for (const name of entries.filter(
|
|
12547
|
+
(item) => item.endsWith(".lesson.json") || item.endsWith(".submitted.json")
|
|
12548
|
+
)) {
|
|
12317
12549
|
if (drafts.length >= limit) break;
|
|
12550
|
+
if (name.endsWith(".submitted.json")) {
|
|
12551
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12552
|
+
const completion = parseSubmittedRecord(
|
|
12553
|
+
await readBoundedFile(
|
|
12554
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12555
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12556
|
+
),
|
|
12557
|
+
draftId
|
|
12558
|
+
);
|
|
12559
|
+
drafts.push(submittedRecordListItem(completion));
|
|
12560
|
+
continue;
|
|
12561
|
+
}
|
|
12318
12562
|
const draft = await readPrivateLessonDraft(
|
|
12319
12563
|
name.slice(0, -".lesson.json".length),
|
|
12320
12564
|
{
|
|
@@ -12333,6 +12577,7 @@ async function listPrivateLessonDrafts(options = {}) {
|
|
|
12333
12577
|
created_at: draft.created_at,
|
|
12334
12578
|
updated_at: draft.updated_at,
|
|
12335
12579
|
submitted_at: draft.submitted_at,
|
|
12580
|
+
completion_expires_at: null,
|
|
12336
12581
|
terminal_reason: draft.terminal_reason,
|
|
12337
12582
|
hold_telemetry_status: draft.hold_telemetry_status,
|
|
12338
12583
|
hold_event_digest: draft.hold_event_digest,
|
|
@@ -12360,6 +12605,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
|
|
|
12360
12605
|
return next;
|
|
12361
12606
|
});
|
|
12362
12607
|
}
|
|
12608
|
+
async function completePrivateLessonSubmission(draftId, input, options = {}) {
|
|
12609
|
+
assertDraftId(draftId);
|
|
12610
|
+
const outbox = await readyOutbox(options);
|
|
12611
|
+
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12612
|
+
await reconcileSubmittedRecordsUnlocked(
|
|
12613
|
+
outbox.directory,
|
|
12614
|
+
options.now ?? /* @__PURE__ */ new Date()
|
|
12615
|
+
);
|
|
12616
|
+
if (existsSync4(tombstonePath(outbox.directory, draftId))) {
|
|
12617
|
+
throw new Error("Private lesson draft was explicitly deleted.");
|
|
12618
|
+
}
|
|
12619
|
+
const existingPath = submittedRecordPath(outbox.directory, draftId);
|
|
12620
|
+
if (existsSync4(existingPath)) {
|
|
12621
|
+
const completion2 = parseSubmittedRecord(
|
|
12622
|
+
await readBoundedFile(
|
|
12623
|
+
existingPath,
|
|
12624
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12625
|
+
),
|
|
12626
|
+
draftId
|
|
12627
|
+
);
|
|
12628
|
+
if (completion2.receipt_digest !== input.receipt_digest) {
|
|
12629
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12630
|
+
}
|
|
12631
|
+
return completion2;
|
|
12632
|
+
}
|
|
12633
|
+
const path = draftPath(outbox.directory, draftId);
|
|
12634
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12635
|
+
const envelope = parseEnvelope(
|
|
12636
|
+
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
12637
|
+
draftId
|
|
12638
|
+
);
|
|
12639
|
+
const current = decryptDraft(envelope, outbox.encryption, true);
|
|
12640
|
+
if (current.state === "superseded_redactor") {
|
|
12641
|
+
throw new Error("Terminal private lesson drafts cannot be submitted.");
|
|
12642
|
+
}
|
|
12643
|
+
if (current.state === "submitted" && current.receipt_digest !== input.receipt_digest) {
|
|
12644
|
+
throw new Error("Private lesson submitted receipt digest conflicts.");
|
|
12645
|
+
}
|
|
12646
|
+
const submittedAt = input.submitted_at ?? options.now ?? /* @__PURE__ */ new Date();
|
|
12647
|
+
const submittedIso = submittedAt.toISOString();
|
|
12648
|
+
const draft = current.state === "submitted" ? current : validateDraft({
|
|
12649
|
+
...current,
|
|
12650
|
+
state: "submitted",
|
|
12651
|
+
attempts: Math.min(1e3, current.attempts + 1),
|
|
12652
|
+
next_retry_at: null,
|
|
12653
|
+
last_error_code: null,
|
|
12654
|
+
submitted_at: submittedIso,
|
|
12655
|
+
receipt_digest: input.receipt_digest,
|
|
12656
|
+
updated_at: submittedIso
|
|
12657
|
+
});
|
|
12658
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
12659
|
+
remote_status: input.remote_status,
|
|
12660
|
+
processing_state: input.processing_state
|
|
12661
|
+
});
|
|
12662
|
+
await atomicWritePrivate(
|
|
12663
|
+
submittedRecordPath(outbox.directory, draftId),
|
|
12664
|
+
`${JSON.stringify(completion)}
|
|
12665
|
+
`
|
|
12666
|
+
);
|
|
12667
|
+
await unlink(path).catch(() => {
|
|
12668
|
+
});
|
|
12669
|
+
await syncDirectory(outbox.directory).catch(() => {
|
|
12670
|
+
});
|
|
12671
|
+
return completion;
|
|
12672
|
+
});
|
|
12673
|
+
}
|
|
12363
12674
|
async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
12364
12675
|
if (!confirmed) {
|
|
12365
12676
|
throw new Error(
|
|
@@ -12370,7 +12681,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12370
12681
|
const outbox = await readyOutbox(options);
|
|
12371
12682
|
return withPrivateLessonOutboxLock(outbox.directory, async () => {
|
|
12372
12683
|
const path = draftPath(outbox.directory, draftId);
|
|
12373
|
-
|
|
12684
|
+
const completionPath = submittedRecordPath(outbox.directory, draftId);
|
|
12685
|
+
if (!existsSync4(path) && !existsSync4(completionPath)) {
|
|
12686
|
+
throw new Error("Private lesson draft does not exist.");
|
|
12687
|
+
}
|
|
12688
|
+
if (existsSync4(path)) {
|
|
12689
|
+
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12690
|
+
}
|
|
12691
|
+
if (existsSync4(completionPath)) {
|
|
12692
|
+
await assertRegularPrivateFile(
|
|
12693
|
+
completionPath,
|
|
12694
|
+
PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
12695
|
+
);
|
|
12696
|
+
}
|
|
12374
12697
|
const tombstone = {
|
|
12375
12698
|
version: "private-lesson-outbox-deletion-v1",
|
|
12376
12699
|
draft_id: draftId,
|
|
@@ -12382,7 +12705,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
|
|
|
12382
12705
|
`${JSON.stringify(tombstone)}
|
|
12383
12706
|
`
|
|
12384
12707
|
);
|
|
12385
|
-
await unlink(path)
|
|
12708
|
+
await unlink(path).catch((error) => {
|
|
12709
|
+
if (!isMissingError(error)) throw error;
|
|
12710
|
+
});
|
|
12711
|
+
await unlink(completionPath).catch((error) => {
|
|
12712
|
+
if (!isMissingError(error)) throw error;
|
|
12713
|
+
});
|
|
12386
12714
|
await syncDirectory(outbox.directory);
|
|
12387
12715
|
return tombstone;
|
|
12388
12716
|
});
|
|
@@ -12415,6 +12743,15 @@ async function privateLessonOutboxSummary(options = {}) {
|
|
|
12415
12743
|
deletionTombstones += 1;
|
|
12416
12744
|
continue;
|
|
12417
12745
|
}
|
|
12746
|
+
if (name.endsWith(".submitted.json")) {
|
|
12747
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12748
|
+
parseSubmittedRecord(
|
|
12749
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12750
|
+
draftId
|
|
12751
|
+
);
|
|
12752
|
+
counts.submitted += 1;
|
|
12753
|
+
continue;
|
|
12754
|
+
}
|
|
12418
12755
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12419
12756
|
const envelope = parseEnvelope(
|
|
12420
12757
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12477,6 +12814,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12477
12814
|
outboxDirectory: directory,
|
|
12478
12815
|
encryption
|
|
12479
12816
|
});
|
|
12817
|
+
} else {
|
|
12818
|
+
await withPrivateLessonOutboxLock(
|
|
12819
|
+
directory,
|
|
12820
|
+
() => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
|
|
12821
|
+
);
|
|
12480
12822
|
}
|
|
12481
12823
|
const entries = await secureOutboxEntries(directory);
|
|
12482
12824
|
const counts = emptyOutboxCounts();
|
|
@@ -12492,6 +12834,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
|
|
|
12492
12834
|
deletionTombstones += 1;
|
|
12493
12835
|
continue;
|
|
12494
12836
|
}
|
|
12837
|
+
if (name.endsWith(".submitted.json")) {
|
|
12838
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
12839
|
+
parseSubmittedRecord(
|
|
12840
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
12841
|
+
draftId
|
|
12842
|
+
);
|
|
12843
|
+
counts.submitted += 1;
|
|
12844
|
+
continue;
|
|
12845
|
+
}
|
|
12495
12846
|
if (!name.endsWith(".lesson.json")) continue;
|
|
12496
12847
|
const envelope = parseEnvelope(
|
|
12497
12848
|
await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
|
|
@@ -12559,7 +12910,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
|
|
|
12559
12910
|
encoded = await readConcurrentPrivateLessonFallbackKey(path);
|
|
12560
12911
|
} catch (error) {
|
|
12561
12912
|
if (!isMissingError(error)) throw error;
|
|
12562
|
-
encoded =
|
|
12913
|
+
encoded = randomBytes3(32).toString("base64url");
|
|
12563
12914
|
try {
|
|
12564
12915
|
await atomicWritePrivate(path, `${encoded}
|
|
12565
12916
|
`);
|
|
@@ -12691,7 +13042,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
|
|
|
12691
13042
|
if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
|
|
12692
13043
|
const stalePath = join4(
|
|
12693
13044
|
directory,
|
|
12694
|
-
`.outbox.lock.stale-${
|
|
13045
|
+
`.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
|
|
12695
13046
|
);
|
|
12696
13047
|
try {
|
|
12697
13048
|
await rename(lockPath, stalePath);
|
|
@@ -12741,14 +13092,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
|
|
|
12741
13092
|
await readBoundedFile(join4(directory, name), 4 * 1024),
|
|
12742
13093
|
name.slice(0, -".deleted.json".length)
|
|
12743
13094
|
);
|
|
12744
|
-
const
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
|
|
12748
|
-
|
|
12749
|
-
|
|
12750
|
-
|
|
12751
|
-
|
|
13095
|
+
const retainedPaths = [
|
|
13096
|
+
{
|
|
13097
|
+
path: draftPath(directory, tombstone.draft_id),
|
|
13098
|
+
maxBytes: PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13099
|
+
},
|
|
13100
|
+
{
|
|
13101
|
+
path: submittedRecordPath(directory, tombstone.draft_id),
|
|
13102
|
+
maxBytes: PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
|
|
13103
|
+
}
|
|
13104
|
+
];
|
|
13105
|
+
for (const retained of retainedPaths) {
|
|
13106
|
+
if (!existsSync4(retained.path)) continue;
|
|
13107
|
+
await assertRegularPrivateFile(retained.path, retained.maxBytes);
|
|
13108
|
+
await unlink(retained.path);
|
|
13109
|
+
changed = true;
|
|
13110
|
+
}
|
|
12752
13111
|
}
|
|
12753
13112
|
if (changed) await syncDirectory(directory);
|
|
12754
13113
|
}
|
|
@@ -12779,7 +13138,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
|
|
|
12779
13138
|
if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
|
|
12780
13139
|
throw new Error("Private lesson draft exceeds its local size limit.");
|
|
12781
13140
|
}
|
|
12782
|
-
const nonce =
|
|
13141
|
+
const nonce = randomBytes3(12);
|
|
12783
13142
|
const aad = Buffer.from(
|
|
12784
13143
|
canonicalJson({
|
|
12785
13144
|
version: "private-lesson-outbox-envelope-v1",
|
|
@@ -12947,6 +13306,135 @@ function terminalizeUnsupportedProfile(draft, now) {
|
|
|
12947
13306
|
};
|
|
12948
13307
|
}
|
|
12949
13308
|
}
|
|
13309
|
+
function submittedRecordFromDraft(draft, remote) {
|
|
13310
|
+
if (draft.state !== "submitted" || !draft.submitted_at || !draft.receipt_digest) {
|
|
13311
|
+
throw new Error("Private lesson submission completion is incomplete.");
|
|
13312
|
+
}
|
|
13313
|
+
return parseSubmittedRecord(
|
|
13314
|
+
JSON.stringify({
|
|
13315
|
+
version: "private-lesson-outbox-submitted-v1",
|
|
13316
|
+
draft_id: draft.draft_id,
|
|
13317
|
+
state: "submitted",
|
|
13318
|
+
payload_digest: draft.payload_digest,
|
|
13319
|
+
redaction_version: draft.redaction_version,
|
|
13320
|
+
redactor_digest: draft.redactor_digest,
|
|
13321
|
+
attempts: draft.attempts,
|
|
13322
|
+
created_at: draft.created_at,
|
|
13323
|
+
updated_at: draft.updated_at,
|
|
13324
|
+
submitted_at: draft.submitted_at,
|
|
13325
|
+
expires_at: new Date(
|
|
13326
|
+
Date.parse(draft.submitted_at) + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS
|
|
13327
|
+
).toISOString(),
|
|
13328
|
+
receipt_digest: draft.receipt_digest,
|
|
13329
|
+
remote_status: remote.remote_status,
|
|
13330
|
+
processing_state: remote.processing_state
|
|
13331
|
+
}),
|
|
13332
|
+
draft.draft_id
|
|
13333
|
+
);
|
|
13334
|
+
}
|
|
13335
|
+
async function compactLegacySubmittedDraftUnlocked(draft, outbox) {
|
|
13336
|
+
const completion = submittedRecordFromDraft(draft, {
|
|
13337
|
+
remote_status: null,
|
|
13338
|
+
processing_state: null
|
|
13339
|
+
});
|
|
13340
|
+
const path = submittedRecordPath(outbox.directory, draft.draft_id);
|
|
13341
|
+
try {
|
|
13342
|
+
await atomicWritePrivate(path, `${JSON.stringify(completion)}
|
|
13343
|
+
`);
|
|
13344
|
+
} catch (error) {
|
|
13345
|
+
if (!isAlreadyExistsError2(error)) throw error;
|
|
13346
|
+
const existing = parseSubmittedRecord(
|
|
13347
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13348
|
+
draft.draft_id
|
|
13349
|
+
);
|
|
13350
|
+
if (existing.payload_digest !== completion.payload_digest || existing.receipt_digest !== completion.receipt_digest) {
|
|
13351
|
+
throw new Error("Private lesson submitted record conflicts.");
|
|
13352
|
+
}
|
|
13353
|
+
}
|
|
13354
|
+
await unlink(draftPath(outbox.directory, draft.draft_id));
|
|
13355
|
+
await syncDirectory(outbox.directory);
|
|
13356
|
+
}
|
|
13357
|
+
async function reconcileSubmittedRecordsUnlocked(directory, now) {
|
|
13358
|
+
const entries = await secureOutboxEntries(directory);
|
|
13359
|
+
let changed = false;
|
|
13360
|
+
for (const name of entries.filter(
|
|
13361
|
+
(entry) => entry.endsWith(".submitted.json")
|
|
13362
|
+
)) {
|
|
13363
|
+
const draftId = name.slice(0, -".submitted.json".length);
|
|
13364
|
+
const path = submittedRecordPath(directory, draftId);
|
|
13365
|
+
const completion = parseSubmittedRecord(
|
|
13366
|
+
await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
|
|
13367
|
+
draftId
|
|
13368
|
+
);
|
|
13369
|
+
const retainedDraft = draftPath(directory, draftId);
|
|
13370
|
+
if (existsSync4(retainedDraft)) {
|
|
13371
|
+
await assertRegularPrivateFile(
|
|
13372
|
+
retainedDraft,
|
|
13373
|
+
PRIVATE_LESSON_DRAFT_MAX_BYTES
|
|
13374
|
+
);
|
|
13375
|
+
await unlink(retainedDraft);
|
|
13376
|
+
changed = true;
|
|
13377
|
+
}
|
|
13378
|
+
if (Date.parse(completion.expires_at) <= now.getTime()) {
|
|
13379
|
+
await unlink(path);
|
|
13380
|
+
changed = true;
|
|
13381
|
+
}
|
|
13382
|
+
}
|
|
13383
|
+
if (changed) await syncDirectory(directory);
|
|
13384
|
+
}
|
|
13385
|
+
function submittedRecordListItem(completion) {
|
|
13386
|
+
return {
|
|
13387
|
+
draft_id: completion.draft_id,
|
|
13388
|
+
state: "submitted",
|
|
13389
|
+
payload_digest: completion.payload_digest,
|
|
13390
|
+
attempts: completion.attempts,
|
|
13391
|
+
next_retry_at: null,
|
|
13392
|
+
last_error_code: null,
|
|
13393
|
+
created_at: completion.created_at,
|
|
13394
|
+
updated_at: completion.updated_at,
|
|
13395
|
+
submitted_at: completion.submitted_at,
|
|
13396
|
+
completion_expires_at: completion.expires_at,
|
|
13397
|
+
terminal_reason: null,
|
|
13398
|
+
hold_telemetry_status: "not_required",
|
|
13399
|
+
hold_event_digest: null,
|
|
13400
|
+
hold_reported_at: null
|
|
13401
|
+
};
|
|
13402
|
+
}
|
|
13403
|
+
function parseSubmittedRecord(serialized, expectedDraftId) {
|
|
13404
|
+
const value = JSON.parse(serialized);
|
|
13405
|
+
const allowedKeys = /* @__PURE__ */ new Set([
|
|
13406
|
+
"version",
|
|
13407
|
+
"draft_id",
|
|
13408
|
+
"state",
|
|
13409
|
+
"payload_digest",
|
|
13410
|
+
"redaction_version",
|
|
13411
|
+
"redactor_digest",
|
|
13412
|
+
"attempts",
|
|
13413
|
+
"created_at",
|
|
13414
|
+
"updated_at",
|
|
13415
|
+
"submitted_at",
|
|
13416
|
+
"expires_at",
|
|
13417
|
+
"receipt_digest",
|
|
13418
|
+
"remote_status",
|
|
13419
|
+
"processing_state"
|
|
13420
|
+
]);
|
|
13421
|
+
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") {
|
|
13422
|
+
throw new Error("Private lesson submitted record is invalid.");
|
|
13423
|
+
}
|
|
13424
|
+
const submittedAt = Date.parse(value.submitted_at);
|
|
13425
|
+
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") {
|
|
13426
|
+
throw new Error("Private lesson submitted record is inconsistent.");
|
|
13427
|
+
}
|
|
13428
|
+
return value;
|
|
13429
|
+
}
|
|
13430
|
+
function isSha256Digest(value) {
|
|
13431
|
+
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
|
|
13432
|
+
}
|
|
13433
|
+
function isIsoTimestamp(value) {
|
|
13434
|
+
if (typeof value !== "string") return false;
|
|
13435
|
+
const parsed = Date.parse(value);
|
|
13436
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
13437
|
+
}
|
|
12950
13438
|
function isHoldTelemetryStatus(value) {
|
|
12951
13439
|
return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
|
|
12952
13440
|
}
|
|
@@ -12994,7 +13482,7 @@ async function atomicReplacePrivate(path, data) {
|
|
|
12994
13482
|
await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
|
|
12995
13483
|
const temporary = join4(
|
|
12996
13484
|
dirname2(path),
|
|
12997
|
-
`.${
|
|
13485
|
+
`.${randomBytes3(12).toString("hex")}.tmp`
|
|
12998
13486
|
);
|
|
12999
13487
|
try {
|
|
13000
13488
|
await atomicWritePrivate(temporary, data);
|
|
@@ -13056,6 +13544,10 @@ function draftPath(directory, draftId) {
|
|
|
13056
13544
|
assertDraftId(draftId);
|
|
13057
13545
|
return join4(directory, `${draftId}.lesson.json`);
|
|
13058
13546
|
}
|
|
13547
|
+
function submittedRecordPath(directory, draftId) {
|
|
13548
|
+
assertDraftId(draftId);
|
|
13549
|
+
return join4(directory, `${draftId}.submitted.json`);
|
|
13550
|
+
}
|
|
13059
13551
|
function tombstonePath(directory, draftId) {
|
|
13060
13552
|
assertDraftId(draftId);
|
|
13061
13553
|
return join4(directory, `${draftId}.deleted.json`);
|
|
@@ -13108,7 +13600,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
|
|
|
13108
13600
|
// src/server.ts
|
|
13109
13601
|
var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
13110
13602
|
var DOCTOR_PROBE_TIMEOUT_MS = 7500;
|
|
13111
|
-
var SERVER_VERSION = true ? "0.1.
|
|
13603
|
+
var SERVER_VERSION = true ? "0.1.76" : "0.0.0-dev";
|
|
13112
13604
|
var tools = toolDefinitions;
|
|
13113
13605
|
var doctorCliRequested = process.argv[2] === "doctor";
|
|
13114
13606
|
var inputBuffer = Buffer.alloc(0);
|
|
@@ -13576,6 +14068,13 @@ async function preparePrivateLessonDraft(input) {
|
|
|
13576
14068
|
async function inspectPrivateLessonOutbox(input) {
|
|
13577
14069
|
const summary = await privateLessonOutboxSummary();
|
|
13578
14070
|
if (input.draft_id) {
|
|
14071
|
+
const completion = await readPrivateLessonSubmittedRecord(input.draft_id);
|
|
14072
|
+
if (completion) {
|
|
14073
|
+
return {
|
|
14074
|
+
draft: privateLessonSubmittedActionResult(completion),
|
|
14075
|
+
outbox: publicOutboxSummary(summary)
|
|
14076
|
+
};
|
|
14077
|
+
}
|
|
13579
14078
|
const draft = await readPrivateLessonDraft(input.draft_id);
|
|
13580
14079
|
return {
|
|
13581
14080
|
draft: privateLessonDraftActionResult(draft),
|
|
@@ -13588,7 +14087,13 @@ async function inspectPrivateLessonOutbox(input) {
|
|
|
13588
14087
|
};
|
|
13589
14088
|
}
|
|
13590
14089
|
async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
13591
|
-
|
|
14090
|
+
const source = await readPrivateLessonSubmissionSource(draftId);
|
|
14091
|
+
if (source.kind === "submitted") {
|
|
14092
|
+
return privateLessonSubmittedActionResult(source.completion, {
|
|
14093
|
+
status: "already_submitted"
|
|
14094
|
+
});
|
|
14095
|
+
}
|
|
14096
|
+
let draft = source.draft;
|
|
13592
14097
|
if (draft.state === "superseded_redactor") {
|
|
13593
14098
|
return privateLessonDraftActionResult(draft, {
|
|
13594
14099
|
status: "superseded_redactor",
|
|
@@ -13596,7 +14101,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13596
14101
|
});
|
|
13597
14102
|
}
|
|
13598
14103
|
if (draft.state === "submitted") {
|
|
13599
|
-
|
|
14104
|
+
if (!draft.receipt_digest) {
|
|
14105
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14106
|
+
"Submitted private lesson draft is missing its receipt digest."
|
|
14107
|
+
);
|
|
14108
|
+
}
|
|
14109
|
+
const migrated = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14110
|
+
receipt_digest: draft.receipt_digest,
|
|
14111
|
+
remote_status: null,
|
|
14112
|
+
processing_state: null,
|
|
14113
|
+
submitted_at: draft.submitted_at ? new Date(draft.submitted_at) : void 0
|
|
14114
|
+
});
|
|
14115
|
+
return privateLessonSubmittedActionResult(migrated, {
|
|
13600
14116
|
status: "already_submitted"
|
|
13601
14117
|
});
|
|
13602
14118
|
}
|
|
@@ -13758,23 +14274,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
|
|
|
13758
14274
|
);
|
|
13759
14275
|
}
|
|
13760
14276
|
const receiptDigest = hashValue(canonicalJson(receipt));
|
|
13761
|
-
|
|
13762
|
-
|
|
14277
|
+
const completionProcessingState = processingState === "queued_for_verification" || processingState === "already_queued" ? processingState : null;
|
|
14278
|
+
if (!completionProcessingState) {
|
|
14279
|
+
throw new PrivateLessonPolicySafetyError(
|
|
14280
|
+
"Private lesson submission omitted its processing state."
|
|
14281
|
+
);
|
|
14282
|
+
}
|
|
14283
|
+
const completion = await completePrivateLessonSubmission(draft.draft_id, {
|
|
14284
|
+
receipt_digest: receiptDigest,
|
|
14285
|
+
remote_status: status,
|
|
14286
|
+
processing_state: completionProcessingState
|
|
14287
|
+
});
|
|
14288
|
+
draft = {
|
|
14289
|
+
...draft,
|
|
13763
14290
|
state: "submitted",
|
|
13764
|
-
attempts:
|
|
14291
|
+
attempts: completion.attempts,
|
|
13765
14292
|
next_retry_at: null,
|
|
13766
14293
|
last_error_code: null,
|
|
13767
|
-
submitted_at:
|
|
13768
|
-
receipt_digest:
|
|
13769
|
-
policy: receiptPolicy
|
|
13770
|
-
|
|
14294
|
+
submitted_at: completion.submitted_at,
|
|
14295
|
+
receipt_digest: completion.receipt_digest,
|
|
14296
|
+
policy: receiptPolicy,
|
|
14297
|
+
updated_at: completion.updated_at
|
|
14298
|
+
};
|
|
13771
14299
|
return {
|
|
13772
|
-
...privateLessonDraftActionResult(draft, {
|
|
14300
|
+
...privateLessonDraftActionResult(draft, {
|
|
14301
|
+
status,
|
|
14302
|
+
completion_expires_at: completion.expires_at
|
|
14303
|
+
}),
|
|
13773
14304
|
receipt,
|
|
13774
14305
|
processing_state: processingState
|
|
13775
14306
|
};
|
|
13776
14307
|
} catch (error) {
|
|
13777
14308
|
draft = await persistPrivateLessonFailure(draft, error);
|
|
14309
|
+
if (draft.state === "submitted") {
|
|
14310
|
+
return privateLessonDraftActionResult(draft, {
|
|
14311
|
+
status: "already_submitted"
|
|
14312
|
+
});
|
|
14313
|
+
}
|
|
13778
14314
|
return privateLessonDraftActionResult(draft, {
|
|
13779
14315
|
status: "not_submitted",
|
|
13780
14316
|
action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
|
|
@@ -14152,13 +14688,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
|
|
|
14152
14688
|
return null;
|
|
14153
14689
|
}
|
|
14154
14690
|
async function persistPrivateLessonFailure(draft, error, explicitCode) {
|
|
14155
|
-
|
|
14156
|
-
|
|
14157
|
-
|
|
14158
|
-
|
|
14159
|
-
|
|
14691
|
+
const completed = await readPrivateLessonSubmittedRecord(draft.draft_id);
|
|
14692
|
+
if (completed) return submittedDraftFromCompletion(draft, completed);
|
|
14693
|
+
try {
|
|
14694
|
+
return await updatePrivateLessonDraft(
|
|
14695
|
+
draft.draft_id,
|
|
14696
|
+
(current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
|
|
14697
|
+
...current,
|
|
14698
|
+
...privateLessonFailureTransition(current, error, explicitCode)
|
|
14699
|
+
}
|
|
14700
|
+
);
|
|
14701
|
+
} catch (updateError) {
|
|
14702
|
+
const racedCompletion = await readPrivateLessonSubmittedRecord(
|
|
14703
|
+
draft.draft_id
|
|
14704
|
+
);
|
|
14705
|
+
if (racedCompletion) {
|
|
14706
|
+
return submittedDraftFromCompletion(draft, racedCompletion);
|
|
14160
14707
|
}
|
|
14161
|
-
|
|
14708
|
+
throw updateError;
|
|
14709
|
+
}
|
|
14710
|
+
}
|
|
14711
|
+
function submittedDraftFromCompletion(draft, completion) {
|
|
14712
|
+
return {
|
|
14713
|
+
...draft,
|
|
14714
|
+
state: "submitted",
|
|
14715
|
+
attempts: completion.attempts,
|
|
14716
|
+
next_retry_at: null,
|
|
14717
|
+
last_error_code: null,
|
|
14718
|
+
submitted_at: completion.submitted_at,
|
|
14719
|
+
receipt_digest: completion.receipt_digest,
|
|
14720
|
+
updated_at: completion.updated_at
|
|
14721
|
+
};
|
|
14162
14722
|
}
|
|
14163
14723
|
async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
|
|
14164
14724
|
return updatePrivateLessonDraft(draft.draft_id, (current) => {
|
|
@@ -14250,6 +14810,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
|
|
|
14250
14810
|
...extra
|
|
14251
14811
|
};
|
|
14252
14812
|
}
|
|
14813
|
+
function privateLessonSubmittedActionResult(completion, extra = {}) {
|
|
14814
|
+
return {
|
|
14815
|
+
draft_id: completion.draft_id,
|
|
14816
|
+
state: "submitted",
|
|
14817
|
+
payload_digest: completion.payload_digest,
|
|
14818
|
+
redaction_version: completion.redaction_version,
|
|
14819
|
+
redactor_digest: completion.redactor_digest,
|
|
14820
|
+
redaction_counts: {},
|
|
14821
|
+
held_reasons: [],
|
|
14822
|
+
attempts: completion.attempts,
|
|
14823
|
+
next_retry_at: null,
|
|
14824
|
+
last_error_code: null,
|
|
14825
|
+
terminal_reason: null,
|
|
14826
|
+
hold_telemetry_status: "not_required",
|
|
14827
|
+
hold_event_digest: null,
|
|
14828
|
+
hold_receipt_digest: null,
|
|
14829
|
+
hold_reported_at: null,
|
|
14830
|
+
submitted_at: completion.submitted_at,
|
|
14831
|
+
completion_expires_at: completion.expires_at,
|
|
14832
|
+
retained_locally: false,
|
|
14833
|
+
...extra
|
|
14834
|
+
};
|
|
14835
|
+
}
|
|
14253
14836
|
function publicOutboxSummary(summary) {
|
|
14254
14837
|
const { directory: _directory, ...safe } = summary;
|
|
14255
14838
|
return safe;
|
|
@@ -14792,6 +15375,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
|
|
|
14792
15375
|
policyResult,
|
|
14793
15376
|
outboxResult
|
|
14794
15377
|
});
|
|
15378
|
+
if (execution === "mcp_tool") {
|
|
15379
|
+
recordConnectionDoctorLifecycleProbe();
|
|
15380
|
+
}
|
|
14795
15381
|
return {
|
|
14796
15382
|
...report,
|
|
14797
15383
|
status: report.status === "healthy" && privateLessonAutomation.status !== "ready" && privateLessonAutomation.status !== "not_applicable" ? "attention" : report.status,
|