@rubytech/create-maxy-code 0.1.460 → 0.1.461
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/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
- package/dist/__tests__/launchd-plist.test.js +26 -0
- package/dist/cron-registration.js +30 -8
- package/dist/index.js +50 -9
- package/dist/launchd-plist.js +1 -1
- package/dist/uninstall.js +9 -3
- package/package.json +1 -1
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +54 -40
- package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +169 -39
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
- package/payload/platform/plugins/cloudflare/mcp/package.json +1 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +21 -19
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +18 -12
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +14 -5
- package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +98 -9
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
- package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +110 -0
- package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +6 -3
- package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
- package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
- package/payload/platform/scripts/logs-rotate.sh +204 -0
- package/payload/platform/scripts/resume-tunnel.sh +27 -0
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
- package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
- package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
- package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
- package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +2 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
- package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
- package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
- package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
- package/payload/server/server.js +55 -35
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +0 -73
|
@@ -10,9 +10,18 @@
|
|
|
10
10
|
* - All callers updated in the same commit.
|
|
11
11
|
*
|
|
12
12
|
* Retrieval order (best to worst):
|
|
13
|
-
* 1.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 1. Corpus nodes whose body carries the most `brief.topic` terms.
|
|
14
|
+
* 2. Within an equal term count, the most recent.
|
|
15
|
+
*
|
|
16
|
+
* `brief.topic` ranks; it never excludes. Until Task 1719 it was applied as
|
|
17
|
+
* `WHERE toLower(body) CONTAINS toLower($topic)` — a single literal
|
|
18
|
+
* whole-phrase match — so a multi-word topic ('programme outline training')
|
|
19
|
+
* matched no body and the walk returned zero exemplars over a non-empty
|
|
20
|
+
* corpus, while `status` still read "ok" because a style card alone satisfies
|
|
21
|
+
* `hasData`. Ranking honours this header's original intent (topic hits first,
|
|
22
|
+
* K backfilled by recency) in one pass: a voice profile conditions on how the
|
|
23
|
+
* operator writes, not what about, so an off-topic exemplar still carries the
|
|
24
|
+
* signal and is always worth more than nothing.
|
|
16
25
|
*
|
|
17
26
|
* Embeddings are not used in v1: the corpus is small (single operator's
|
|
18
27
|
* personal writing — typically <500 docs), and recency-with-keyword-filter
|
|
@@ -36,6 +45,16 @@ const CHARS_PER_TOKEN = 4;
|
|
|
36
45
|
function errorResult(error) {
|
|
37
46
|
return { styleCard: null, exemplars: [], status: "error", error };
|
|
38
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Cypher `size()` arrives as a driver Integer, not a JS number, and an Integer
|
|
50
|
+
* is truthy at zero — so comparing the raw value would count every row as a
|
|
51
|
+
* topic hit. Narrow it before any arithmetic.
|
|
52
|
+
*/
|
|
53
|
+
function toNumber(value) {
|
|
54
|
+
if (neo4j.isInt(value))
|
|
55
|
+
return value.toNumber();
|
|
56
|
+
return typeof value === "number" ? value : 0;
|
|
57
|
+
}
|
|
39
58
|
export async function voiceRetrieveConditioning(params) {
|
|
40
59
|
const { accountId, userId, brief } = params;
|
|
41
60
|
const scope = brief.scope ?? "personal";
|
|
@@ -55,12 +74,18 @@ export async function voiceRetrieveConditioning(params) {
|
|
|
55
74
|
(length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
|
|
56
75
|
const charBudget = tokenBudget * CHARS_PER_TOKEN;
|
|
57
76
|
const format = brief.format;
|
|
58
|
-
|
|
59
|
-
|
|
77
|
+
// Distinct lowercased topic terms. Lowercasing happens here, once, so the
|
|
78
|
+
// Cypher compares term-to-body without a per-row toLower on the term. An
|
|
79
|
+
// absent/blank topic yields [], which scores every row 0 and leaves the
|
|
80
|
+
// ordering to recency alone — the same walk, no branch.
|
|
81
|
+
const topicTerms = Array.from(new Set((brief.topic ?? "")
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.split(/\s+/)
|
|
84
|
+
.filter((t) => t.length > 0)));
|
|
60
85
|
// Body resolution (Task 471): for :KnowledgeDocument with HAS_SECTION
|
|
61
86
|
// children, concatenate child :Section bodies in `position` order (KD.body is
|
|
62
87
|
// null after Task 465 server-slicing); other labels read n.body via the
|
|
63
|
-
// COALESCE chain.
|
|
88
|
+
// COALESCE chain. Topic ranking scores against the computed body.
|
|
64
89
|
const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
|
|
65
90
|
OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
|
|
66
91
|
WHERE ${notTrashed("s")}
|
|
@@ -86,30 +111,29 @@ export async function voiceRetrieveConditioning(params) {
|
|
|
86
111
|
const profile = await session.run(`OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
|
|
87
112
|
RETURN p.styleCard AS styleCard`, { accountId, profileUserId, format });
|
|
88
113
|
const styleCard = profile.records[0]?.get("styleCard") ?? null;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
coalesce(n.subject, n.title, n.name, '') AS source, ts
|
|
96
|
-
ORDER BY ts IS NULL, ts DESC
|
|
97
|
-
LIMIT $k`
|
|
98
|
-
: `MATCH (n)
|
|
114
|
+
// `topicScore` counts how many distinct topic terms the body carries and
|
|
115
|
+
// orders on it. It is a rank, not a predicate: a row scoring 0 stays in
|
|
116
|
+
// the result set and is simply ordered behind the hits, so the walk
|
|
117
|
+
// returns up to K exemplars whenever the corpus holds any, whatever the
|
|
118
|
+
// topic. `ts IS NULL` keeps undated nodes last within an equal score.
|
|
119
|
+
const cypher = `MATCH (n)
|
|
99
120
|
WHERE ${exemplarWhere}
|
|
100
121
|
${bodyComputeBlock}
|
|
122
|
+
WITH n, ts, body,
|
|
123
|
+
size([t IN $topicTerms WHERE toLower(body) CONTAINS t]) AS topicScore
|
|
101
124
|
RETURN elementId(n) AS id, labels(n) AS labels, body,
|
|
102
|
-
coalesce(n.subject, n.title, n.name, '') AS source, ts
|
|
103
|
-
ORDER BY ts IS NULL, ts DESC
|
|
125
|
+
coalesce(n.subject, n.title, n.name, '') AS source, ts, topicScore
|
|
126
|
+
ORDER BY topicScore DESC, ts IS NULL, ts DESC
|
|
104
127
|
LIMIT $k`;
|
|
105
128
|
const result = await session.run(cypher, {
|
|
106
129
|
...exemplarParams,
|
|
107
|
-
|
|
130
|
+
topicTerms,
|
|
108
131
|
// neo4j-driver serializes a plain JS number as a Cypher float (15 →
|
|
109
132
|
// 15.0), which LIMIT rejects. Send a Neo4j integer.
|
|
110
133
|
k: neo4j.int(k),
|
|
111
134
|
});
|
|
112
135
|
let charsUsed = 0;
|
|
136
|
+
let topicHits = 0;
|
|
113
137
|
const exemplars = [];
|
|
114
138
|
for (const r of result.records) {
|
|
115
139
|
const body = r.get("body") ?? "";
|
|
@@ -128,8 +152,10 @@ export async function voiceRetrieveConditioning(params) {
|
|
|
128
152
|
source: (r.get("source") ?? "").slice(0, 200),
|
|
129
153
|
});
|
|
130
154
|
charsUsed += truncated.length;
|
|
155
|
+
if (toNumber(r.get("topicScore")) > 0)
|
|
156
|
+
topicHits += 1;
|
|
131
157
|
}
|
|
132
|
-
return { styleCard, exemplars };
|
|
158
|
+
return { styleCard, exemplars, topicHits };
|
|
133
159
|
};
|
|
134
160
|
const hasData = (r) => (r.styleCard !== null && r.styleCard.length > 0) || r.exemplars.length > 0;
|
|
135
161
|
const orgLookup = () => lookup(ORG_USER_ID, voiceCorpusWhereWithFormat(format), { accountId, format });
|
|
@@ -154,8 +180,15 @@ export async function voiceRetrieveConditioning(params) {
|
|
|
154
180
|
result = org;
|
|
155
181
|
status = hasData(org) ? "ok" : "no-data";
|
|
156
182
|
}
|
|
183
|
+
// `topicTerms`/`topicHits` are the pair that makes a thin result legible
|
|
184
|
+
// without reproducing it: exemplarCount=0 says the scope's corpus is empty,
|
|
185
|
+
// and topicHits=0 with topicTerms>0 says the exemplars are recency-picked
|
|
186
|
+
// rather than topic-matched. Before Task 1719 the two were indistinguishable
|
|
187
|
+
// — a topic that matched nothing and an absent corpus both logged
|
|
188
|
+
// exemplarCount=0, and only the first was a defect.
|
|
157
189
|
process.stderr.write(`[voice-retrieve] scope=${scope} format=${format} status=${status} ` +
|
|
158
|
-
`styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length}
|
|
190
|
+
`styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length} ` +
|
|
191
|
+
`topicTerms=${topicTerms.length} topicHits=${result.topicHits} tokenBudget=${tokenBudget}\n`);
|
|
159
192
|
return { styleCard: result.styleCard, exemplars: result.exemplars, status };
|
|
160
193
|
}
|
|
161
194
|
catch (err) {
|
package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"voice-retrieve-conditioning.js","sourceRoot":"","sources":["../../src/tools/voice-retrieve-conditioning.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"voice-retrieve-conditioning.js","sourceRoot":"","sources":["../../src/tools/voice-retrieve-conditioning.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,KAAuB,MAAM,cAAc,CAAC;AACnD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EACL,0BAA0B,EAC1B,mCAAmC,EACnC,WAAW,GAGZ,MAAM,wBAAwB,CAAC;AAEhC,MAAM,0BAA0B,GAAG,MAAM,CAAC;AAC1C,MAAM,yBAAyB,GAAG,MAAM,CAAC;AACzC,MAAM,eAAe,GAAG,CAAC,CAAC;AAgE1B,SAAS,WAAW,CAAC,KAAa;IAChC,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;AACpE,CAAC;AAED;;;;GAIG;AACH,SAAS,QAAQ,CAAC,KAAc;IAC9B,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC;QAAE,OAAQ,KAAuB,CAAC,QAAQ,EAAE,CAAC;IACnE,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,MAAuC;IAEvC,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC;IAC5C,MAAM,KAAK,GAAe,KAAK,CAAC,KAAK,IAAI,UAAU,CAAC;IACpD,6EAA6E;IAC7E,4EAA4E;IAC5E,8EAA8E;IAC9E,8EAA8E;IAC9E,6EAA6E;IAC7E,gFAAgF;IAChF,IAAI,CAAC,SAAS,IAAI,CAAC,KAAK,KAAK,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;QAC/C,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,kDAAkD,KAAK,cAAc,OAAO,CAAC,SAAS,CAAC,WAAW,OAAO,CAAC,MAAM,CAAC,IAAI,CACtH,CAAC;QACF,OAAO,WAAW,CAAC,0BAA0B,CAAC,CAAC;IACjD,CAAC;IAED,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,IAAI,OAAO,CAAC;IACvC,MAAM,CAAC,GAAG,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACrC,MAAM,WAAW,GACf,MAAM,CAAC,WAAW;QAClB,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,yBAAyB,CAAC,CAAC,CAAC,0BAA0B,CAAC,CAAC;IAC/E,MAAM,UAAU,GAAG,WAAW,GAAG,eAAe,CAAC;IACjD,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;IAC5B,0EAA0E;IAC1E,yEAAyE;IACzE,wEAAwE;IACxE,wDAAwD;IACxD,MAAM,UAAU,GAAG,KAAK,CAAC,IAAI,CAC3B,IAAI,GAAG,CACL,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC;SAChB,WAAW,EAAE;SACb,KAAK,CAAC,KAAK,CAAC;SACZ,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAC/B,CACF,CAAC;IAEF,sEAAsE;IACtE,8EAA8E;IAC9E,wEAAwE;IACxE,kEAAkE;IAClE,MAAM,gBAAgB,GAAG;;iBAEV,UAAU,CAAC,GAAG,CAAC;;;;;;;qBAOX,CAAC;IAEpB,4EAA4E;IAC5E,0EAA0E;IAC1E,yEAAyE;IACzE,cAAc;IACd,IAAI,OAA4B,CAAC;IACjC,IAAI,CAAC;QACH,OAAO,GAAG,UAAU,EAAE,CAAC;QAEvB,4EAA4E;QAC5E,yEAAyE;QACzE,uEAAuE;QACvE,sEAAsE;QACtE,MAAM,MAAM,GAAG,KAAK,EAClB,aAAqB,EACrB,aAAqB,EACrB,cAAuC,EAKtC,EAAE;YACH,MAAM,OAAO,GAAG,MAAM,OAAQ,CAAC,GAAG,CAChC;yCACiC,EACjC,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,CACrC,CAAC;YACF,MAAM,SAAS,GACZ,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,WAAW,CAAmB,IAAI,IAAI,CAAC;YAElE,yEAAyE;YACzE,wEAAwE;YACxE,oEAAoE;YACpE,wEAAwE;YACxE,sEAAsE;YACtE,MAAM,MAAM,GAAG;mBACF,aAAa;aACnB,gBAAgB;;;;;;oBAMT,CAAC;YAEf,MAAM,MAAM,GAAG,MAAM,OAAQ,CAAC,GAAG,CAAC,MAAM,EAAE;gBACxC,GAAG,cAAc;gBACjB,UAAU;gBACV,oEAAoE;gBACpE,oDAAoD;gBACpD,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;aAChB,CAAC,CAAC;YAEH,IAAI,SAAS,GAAG,CAAC,CAAC;YAClB,IAAI,SAAS,GAAG,CAAC,CAAC;YAClB,MAAM,SAAS,GAAoB,EAAE,CAAC;YACtC,KAAK,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;gBAC/B,MAAM,IAAI,GAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAmB,IAAI,EAAE,CAAC;gBACpD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;oBAAE,SAAS;gBAChC,MAAM,MAAM,GAAI,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAc,IAAI,EAAE,CAAC;gBACnD,MAAM,KAAK,GACT,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAChB,CAAC,mBAAmB,EAAE,SAAS,EAAE,YAAY,EAAE,cAAc,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC3E,IAAI,MAAM,CAAC,CAAC,CAAC,IAAI,SAAS,CAAC;gBAC9B,MAAM,SAAS,GAAG,UAAU,GAAG,SAAS,CAAC;gBACzC,IAAI,SAAS,IAAI,CAAC;oBAAE,MAAM;gBAC1B,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;gBAC5E,SAAS,CAAC,IAAI,CAAC;oBACb,MAAM,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAW;oBAC7B,KAAK;oBACL,IAAI,EAAE,SAAS;oBACf,MAAM,EAAE,CAAE,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAmB,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC;iBACjE,CAAC,CAAC;gBACH,SAAS,IAAI,SAAS,CAAC,MAAM,CAAC;gBAC9B,IAAI,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,GAAG,CAAC;oBAAE,SAAS,IAAI,CAAC,CAAC;YACxD,CAAC;YACD,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC;QAC7C,CAAC,CAAC;QAEF,MAAM,OAAO,GAAG,CAAC,CAA2D,EAAE,EAAE,CAC9E,CAAC,CAAC,CAAC,SAAS,KAAK,IAAI,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,CAAC;QAE7E,MAAM,SAAS,GAAG,GAAG,EAAE,CACrB,MAAM,CAAC,WAAW,EAAE,0BAA0B,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,CAAC,CAAC;QAEjF,IAAI,MAIH,CAAC;QACF,IAAI,MAA2B,CAAC;QAChC,IAAI,KAAK,KAAK,UAAU,EAAE,CAAC;YACzB,MAAM,QAAQ,GAAG,MAAM,MAAM,CAC3B,MAAM,EACN,mCAAmC,CAAC,MAAM,CAAC,EAC3C,EAAE,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,CAC3C,CAAC;YACF,IAAI,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACtB,MAAM,GAAG,QAAQ,CAAC;gBAClB,MAAM,GAAG,IAAI,CAAC;YAChB,CAAC;iBAAM,CAAC;gBACN,uEAAuE;gBACvE,6CAA6C;gBAC7C,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;gBAC9B,MAAM,GAAG,GAAG,CAAC;gBACb,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC;YACrD,CAAC;QACH,CAAC;aAAM,CAAC;YACN,MAAM,GAAG,GAAG,MAAM,SAAS,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,CAAC;YACb,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;QAC3C,CAAC;QAED,yEAAyE;QACzE,4EAA4E;QAC5E,0EAA0E;QAC1E,6EAA6E;QAC7E,kEAAkE;QAClE,oDAAoD;QACpD,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,0BAA0B,KAAK,WAAW,MAAM,WAAW,MAAM,GAAG;YAClE,kBAAkB,MAAM,CAAC,SAAS,EAAE,MAAM,IAAI,CAAC,kBAAkB,MAAM,CAAC,SAAS,CAAC,MAAM,GAAG;YAC3F,cAAc,UAAU,CAAC,MAAM,cAAc,MAAM,CAAC,SAAS,gBAAgB,WAAW,IAAI,CAC/F,CAAC;QAEF,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC;IAC9E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACjE,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,OAAO,IAAI,CAAC,CAAC;QAC7D,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;IAC9B,CAAC;YAAS,CAAC;QACT,IAAI,OAAO;YAAE,MAAM,OAAO,CAAC,KAAK,EAAE,CAAC;IACrC,CAAC;AACH,CAAC"}
|
|
@@ -644,6 +644,63 @@ check("voice-distil-profile keeps both structural write anchors", () => {
|
|
|
644
644
|
assert.equal(anchors?.length, 2, "both write-path anchors retained");
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
check("both write paths aggregate the sample link in a CALL subquery", () => {
|
|
648
|
+
// The load-bearing invariant of the zero-record fix. A bare
|
|
649
|
+
// `WITH p UNWIND $ids ... RETURN elementId(p)` tail collapses the row when the
|
|
650
|
+
// id list is empty or every id is stale, so records.length === 0 stops meaning
|
|
651
|
+
// "the anchor missed" and the write reports an identity error for what is a
|
|
652
|
+
// content condition. The CALL subquery's bare aggregation returns a row over
|
|
653
|
+
// empty input, which is what keeps the outer row alive. Reverting either tail
|
|
654
|
+
// reintroduces the bug silently, so pin the shape here.
|
|
655
|
+
const subqueries = distilSrc.match(/RETURN count\(\*\) AS learned/g);
|
|
656
|
+
assert.equal(subqueries?.length, 2, "both write paths aggregate with count(*)");
|
|
657
|
+
// Positive form: each sample UNWIND must sit inside a CALL block. A negative
|
|
658
|
+
// "no bare UNWIND" regex cannot tell the subquery's own UNWIND from an outer
|
|
659
|
+
// one, so it fires on correct code.
|
|
660
|
+
const wrapped = distilSrc.match(
|
|
661
|
+
/CALL \{\s*\n\s*WITH p\s*\n\s*UNWIND \$(sampleIds|amendIds) AS sid/g,
|
|
662
|
+
);
|
|
663
|
+
assert.equal(wrapped?.length, 2, "both sample links are wrapped in a CALL subquery");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
check("the zero-resolved write reports content, never identity", () => {
|
|
667
|
+
// The whole point of the fix: a merged profile with no sources must not reuse
|
|
668
|
+
// the unknown-:AdminUser wording, and must not tell the caller their id is
|
|
669
|
+
// wrong. Both messages disclaim identity explicitly.
|
|
670
|
+
// The messages are built by concatenating template literals, so a sentence can
|
|
671
|
+
// straddle a `+ ` join in source while being whole at runtime. Flatten the
|
|
672
|
+
// joins before matching prose, or these assertions test the formatting rather
|
|
673
|
+
// than the message.
|
|
674
|
+
const flat = distilSrc.replace(/`\s*\+\s*\n\s*`/g, "");
|
|
675
|
+
const disclaimers = flat.match(
|
|
676
|
+
/This is a content condition, not an identity one\./g,
|
|
677
|
+
);
|
|
678
|
+
assert.equal(disclaimers?.length, 2, "both zero-resolved messages disclaim identity");
|
|
679
|
+
assert.ok(
|
|
680
|
+
/none carried readable text to sample/.test(flat),
|
|
681
|
+
"the empty-sample cause has its own wording",
|
|
682
|
+
);
|
|
683
|
+
assert.ok(
|
|
684
|
+
/stopped resolving/.test(flat),
|
|
685
|
+
"the vanished-samples cause has its own wording",
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
check("the vanished-samples message does not blame trashing", () => {
|
|
690
|
+
// A trashed node keeps its elementId, and the write resolves with a bare
|
|
691
|
+
// `MATCH (s) WHERE elementId(s) = sid` carrying no trash predicate, so it
|
|
692
|
+
// still resolves and still takes its edge. Only a hard delete can produce the
|
|
693
|
+
// condition. Naming trashing sends the operator to a trash that cannot hold
|
|
694
|
+
// the cause, which is the same misattribution the fix removes.
|
|
695
|
+
const at = distilSrc.indexOf("function noSourcesResolvedMessage");
|
|
696
|
+
assert.ok(at > -1, "noSourcesResolvedMessage present");
|
|
697
|
+
const body = distilSrc.slice(at, at + 1200);
|
|
698
|
+
assert.ok(
|
|
699
|
+
!/deleted or trashed/.test(body),
|
|
700
|
+
"must not name trashing as a cause of an unresolved id",
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
|
|
647
704
|
const retrieveSrc = readFileSync(
|
|
648
705
|
join(TOOLS_SRC, "voice-retrieve-conditioning.ts"),
|
|
649
706
|
"utf-8",
|
|
@@ -685,6 +742,59 @@ check("voice-retrieve-conditioning never sets isError", () => {
|
|
|
685
742
|
);
|
|
686
743
|
});
|
|
687
744
|
|
|
745
|
+
// Comments stripped, so a check that forbids a Cypher clause cannot be tripped
|
|
746
|
+
// by documentation *describing* that clause. The header of
|
|
747
|
+
// voice-retrieve-conditioning.ts quotes the topic-rank predicate verbatim to
|
|
748
|
+
// explain why it went; that prose must not read as the predicate itself.
|
|
749
|
+
const retrieveCode = retrieveSrc
|
|
750
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
751
|
+
.replace(/^\s*\/\/.*$/gm, "");
|
|
752
|
+
|
|
753
|
+
check("voice-retrieve-conditioning topic ranks and never excludes", () => {
|
|
754
|
+
// The regression guard for the reported defect: brief.topic was applied as a
|
|
755
|
+
// literal whole-phrase predicate, so a multi-word topic matched no body and
|
|
756
|
+
// the walk returned zero exemplars over a non-empty corpus. Any predicate
|
|
757
|
+
// that tests the topic inside a WHERE reintroduces it, whether phrased on
|
|
758
|
+
// $topic or on the terms — the topic belongs in the ORDER BY alone.
|
|
759
|
+
assert.ok(
|
|
760
|
+
!/CONTAINS\s+toLower\(\$topic\)/.test(retrieveCode),
|
|
761
|
+
"topic must not filter the exemplar walk: a non-matching topic empties a non-empty corpus",
|
|
762
|
+
);
|
|
763
|
+
assert.ok(
|
|
764
|
+
!/WHERE[^\n]*\$topicTerms/.test(retrieveCode),
|
|
765
|
+
"topic terms must not appear in a WHERE that gates the walk, only in the ranked projection",
|
|
766
|
+
);
|
|
767
|
+
assert.ok(
|
|
768
|
+
/ORDER BY topicScore DESC/.test(retrieveCode),
|
|
769
|
+
"topic must rank the walk: topicScore orders, recency breaks the tie",
|
|
770
|
+
);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
check("voice-retrieve-conditioning narrows Cypher integers before comparing", () => {
|
|
774
|
+
// A driver Integer is truthy at zero, so `topicScore > 0` on the raw value
|
|
775
|
+
// would count every row as a topic hit and make the observability line lie.
|
|
776
|
+
assert.ok(
|
|
777
|
+
/toNumber\(r\.get\("topicScore"\)\)\s*>\s*0/.test(retrieveSrc),
|
|
778
|
+
"topicScore must be narrowed via toNumber() before the hit comparison",
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
check("voice-retrieve-conditioning builds distinct lowercased topic terms", () => {
|
|
783
|
+
// Asserted on the source, never on dist/: dist is a build artifact that
|
|
784
|
+
// survives a source revert, so a check reading it passes against code that no
|
|
785
|
+
// longer exists. The parse is what turns a phrase into rankable terms —
|
|
786
|
+
// lowercased once here so the Cypher compares term-to-body directly, and
|
|
787
|
+
// de-duplicated so a repeated word cannot inflate topicScore.
|
|
788
|
+
assert.ok(
|
|
789
|
+
/\.toLowerCase\(\)[\s\S]{0,60}\.split\(\/\\s\+\/\)/.test(retrieveSrc),
|
|
790
|
+
"topic must be lowercased and split on whitespace into terms",
|
|
791
|
+
);
|
|
792
|
+
assert.ok(
|
|
793
|
+
/const topicTerms = Array\.from\(\s*new Set\(/.test(retrieveSrc),
|
|
794
|
+
"topic terms must be de-duplicated so a repeated word cannot inflate topicScore",
|
|
795
|
+
);
|
|
796
|
+
});
|
|
797
|
+
|
|
688
798
|
// ---------------------------------------------------------------------------
|
|
689
799
|
// 14. One refusal wording across every gate (Task 1701).
|
|
690
800
|
// record-feedback and ingest already enforced the rule via an inline
|
|
@@ -115,7 +115,7 @@ This runs only on the admin seat (the tool is admin-allowlisted). Accounts witho
|
|
|
115
115
|
|
|
116
116
|
## Flow — drafting-time conditioning
|
|
117
117
|
|
|
118
|
-
Drafting skills (email composition, Postiz, property-brochure, investor-data-room Stage 3 prose, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure, the investor-data-room Stage 3 business plan, and the prospectus skills request `org`. It returns:
|
|
118
|
+
Drafting skills (email composition, Postiz, property-brochure, investor-data-room Stage 3 prose, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure, the investor-data-room Stage 3 business plan, and the prospectus skills request `org`. `topic` is optional and only ranks the exemplars: its terms sort the corpus by how many they match, and a topic matching nothing still returns the most recent writing. Describe the subject as naturally as you like, because no phrasing can cost you exemplars. It returns:
|
|
119
119
|
|
|
120
120
|
```yaml
|
|
121
121
|
styleCard: |
|
|
@@ -209,7 +209,9 @@ A value naming no `:AdminUser` on the account is refused, and nothing is written
|
|
|
209
209
|
|
|
210
210
|
The tools resolve identity from the spawn environment on their own. Pass `author:` only to attribute content to a different operator on a multi-operator account, and only with that operator's real `:AdminUser` id.
|
|
211
211
|
|
|
212
|
-
If a tool refuses
|
|
212
|
+
If a tool refuses *on identity*, the id is wrong. Do not retry with a different guess. The account's real admin identity is the operator's to supply.
|
|
213
|
+
|
|
214
|
+
Not every refusal is an identity refusal. `voice-distil-profile` also refuses when the profile merged but no source content could be linked to it, either because no sampled item carried readable text or because the sampled items stopped resolving mid-run. Those messages say so, and close with "This is a content condition, not an identity one." Changing the id will not fix them, and the `[voice-identity] refuse` line is absent when they fire.
|
|
213
215
|
|
|
214
216
|
`__org__` is exempt, because the house voice anchors on the business rather than on a person.
|
|
215
217
|
|
|
@@ -222,6 +224,7 @@ If a tool refuses, the id is wrong. Do not retry with a different guess. The acc
|
|
|
222
224
|
| `[voice-distil] scope=<scope> userId=<id\|__org__> format=<format> skip reason=<below-threshold\|recent\|empty-corpus>` | When the cadence/empty guard fires. |
|
|
223
225
|
| `[voice-distil] scope=<scope> mode=amend userId=<id\|__org__> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
|
|
224
226
|
| `[voice-distil] scope=<scope> mode=write userId=<id\|__org__> anchor=<…> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
|
|
227
|
+
| `[voice-distil] scope=<scope> userId=<id\|__org__> anchor=<…> format=<format> mode=write sources=none attempted=<n> resolved=0 ...` | The profile merged and the style card saved, but no `LEARNED_FROM` edge was written. `attempted=0` means no sampled item carried readable text; `attempted>0` means every sampled item stopped resolving between the walk and the write. Either way the call throws, and the cause is content, not identity. |
|
|
225
228
|
| `[voice-retrieve] scope=<scope> format=<format> status=<ok\|fallback-org\|no-data\|error> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. `status=fallback-org` proves the personal→org fallback fired. |
|
|
226
229
|
| `[voice-record-feedback] scope=<scope> userId=<id\|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
|
|
227
230
|
| `[voice-ingest-session-text] sessionId=<id> adminUser=<id> turns=<n> bytes=<n> skipped=<m>` | On-demand session-text capture. |
|
|
@@ -229,7 +232,7 @@ If a tool refuses, the id is wrong. Do not retry with a different guess. The acc
|
|
|
229
232
|
|
|
230
233
|
**Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active format per operator. `[voice-ingest-session-text] turns≥1` appears when the operator asks to capture a session's voice.
|
|
231
234
|
|
|
232
|
-
**Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead.
|
|
235
|
+
**Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; `[voice-distil] … sources=none attempted=0` → the corpus for that format is tagged but holds no readable text, so the profile has a style card and no sources; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead.
|
|
233
236
|
|
|
234
237
|
**Diagnostic path:**
|
|
235
238
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Tests for logs-rotate.sh (Task 1706). Fixture-driven: builds a throwaway logs
|
|
3
|
+
# dir per case, runs the real script against it, asserts on the resulting files.
|
|
4
|
+
# No stubs — gzip/find/wc/date are the real binaries, because the portability of
|
|
5
|
+
# exactly those calls across Linux and darwin is part of what this asserts.
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
+
ROTATE="$SCRIPT_DIR/../logs-rotate.sh"
|
|
10
|
+
|
|
11
|
+
PASS=0
|
|
12
|
+
FAIL=0
|
|
13
|
+
|
|
14
|
+
run_case() {
|
|
15
|
+
local name="$1"; shift
|
|
16
|
+
echo ""
|
|
17
|
+
echo "=== CASE: $name ==="
|
|
18
|
+
if "$@"; then
|
|
19
|
+
PASS=$((PASS + 1))
|
|
20
|
+
echo "PASS: $name"
|
|
21
|
+
else
|
|
22
|
+
FAIL=$((FAIL + 1))
|
|
23
|
+
echo "FAIL: $name"
|
|
24
|
+
fi
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Build a throwaway logs dir. Echoes its path.
|
|
28
|
+
mk_logs_dir() {
|
|
29
|
+
local d
|
|
30
|
+
d="$(mktemp -d "${TMPDIR:-/tmp}/logs-rotate-test.XXXXXX")"
|
|
31
|
+
echo "$d"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Write a file of exactly $2 bytes at $1. $2 must be a whole number of MiB.
|
|
35
|
+
# bs=1048576 rather than bs=1m/1M: the suffix spellings differ between BSD and
|
|
36
|
+
# GNU dd, and this suite runs on both.
|
|
37
|
+
mk_file_of_size() {
|
|
38
|
+
local path="$1" bytes="$2" mib
|
|
39
|
+
mib=$(( bytes / 1048576 ))
|
|
40
|
+
dd if=/dev/zero bs=1048576 count="$mib" 2>/dev/null | tr '\0' 'x' > "$path"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }
|
|
44
|
+
|
|
45
|
+
# Backdate a path by $2 days. `date -r` is BSD, `date -d @` is GNU.
|
|
46
|
+
backdate() {
|
|
47
|
+
local path="$1" days="$2" secs
|
|
48
|
+
secs=$(( $(date +%s) - days * 86400 ))
|
|
49
|
+
touch -t "$(date -u -r "$secs" +%Y%m%d%H%M 2>/dev/null || date -u -d "@$secs" +%Y%m%d%H%M)" "$path"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case_under_threshold_is_noop() {
|
|
53
|
+
local d; d="$(mk_logs_dir)"
|
|
54
|
+
# 63 MiB — just under the 64 MiB threshold, so this also pins the boundary.
|
|
55
|
+
mk_file_of_size "$d/server.log" $((63 * 1024 * 1024))
|
|
56
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
57
|
+
local rc=$?
|
|
58
|
+
[ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$d"; return 1; }
|
|
59
|
+
[ ! -e "$d/server.log.1.gz" ] || { echo " rotated a 63 MiB file"; rm -rf "$d"; return 1; }
|
|
60
|
+
[ "$(size_of "$d/server.log")" -eq $((63 * 1024 * 1024)) ] \
|
|
61
|
+
|| { echo " file was modified"; rm -rf "$d"; return 1; }
|
|
62
|
+
rm -rf "$d"; return 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case_over_threshold_rotates_and_truncates() {
|
|
66
|
+
local d; d="$(mk_logs_dir)"
|
|
67
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
68
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
69
|
+
[ -f "$d/server.log.1.gz" ] || { echo " no .1.gz produced"; rm -rf "$d"; return 1; }
|
|
70
|
+
# The live file must still exist (readers hardcode the path) and be small:
|
|
71
|
+
# it holds only the marker line.
|
|
72
|
+
[ -f "$d/server.log" ] || { echo " live path missing after rotate"; rm -rf "$d"; return 1; }
|
|
73
|
+
local live; live="$(size_of "$d/server.log")"
|
|
74
|
+
[ "$live" -lt 4096 ] || { echo " live file not truncated: $live bytes"; rm -rf "$d"; return 1; }
|
|
75
|
+
rm -rf "$d"; return 0
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case_marker_line_present() {
|
|
79
|
+
local d; d="$(mk_logs_dir)"
|
|
80
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
81
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
82
|
+
grep -q '\[logs-rotate\] rotated file=server.log prev-bytes=68157440 prev-file=server.log.1.gz keep=5' "$d/server.log" \
|
|
83
|
+
|| { echo " marker line absent or malformed:"; cat "$d/server.log"; rm -rf "$d"; return 1; }
|
|
84
|
+
# The marker must be datable from itself.
|
|
85
|
+
grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z \[logs-rotate\]' "$d/server.log" \
|
|
86
|
+
|| { echo " marker line not timestamped"; rm -rf "$d"; return 1; }
|
|
87
|
+
rm -rf "$d"; return 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case_generation_shift_drops_the_sixth() {
|
|
91
|
+
local d; d="$(mk_logs_dir)"
|
|
92
|
+
# Seed 5 existing generations, each identifiable by content.
|
|
93
|
+
local i
|
|
94
|
+
for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
|
|
95
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
96
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
97
|
+
# .5.gz must now hold what .4.gz held; the old .5.gz is gone.
|
|
98
|
+
[ "$(gzip -cd "$d/server.log.5.gz")" = "gen4" ] || { echo " .5.gz is not the old .4.gz"; rm -rf "$d"; return 1; }
|
|
99
|
+
[ "$(gzip -cd "$d/server.log.2.gz")" = "gen1" ] || { echo " .2.gz is not the old .1.gz"; rm -rf "$d"; return 1; }
|
|
100
|
+
[ ! -e "$d/server.log.6.gz" ] || { echo " a 6th generation was created"; rm -rf "$d"; return 1; }
|
|
101
|
+
rm -rf "$d"; return 0
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case_lock_held_is_noop_exit_zero() {
|
|
105
|
+
local d; d="$(mk_logs_dir)"
|
|
106
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
107
|
+
mkdir "$d/.rotate.lock" # fresh lock, not stale
|
|
108
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
109
|
+
local rc=$?
|
|
110
|
+
[ "$rc" -eq 0 ] || { echo " expected exit 0 when lock held, got $rc"; rm -rf "$d"; return 1; }
|
|
111
|
+
[ ! -e "$d/server.log.1.gz" ] || { echo " rotated despite held lock"; rm -rf "$d"; return 1; }
|
|
112
|
+
# A held lock must survive the tick that could not take it.
|
|
113
|
+
[ -d "$d/.rotate.lock" ] || { echo " another holder's lock was removed"; rm -rf "$d"; return 1; }
|
|
114
|
+
rm -rf "$d"; return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case_stale_lock_is_reclaimed() {
|
|
118
|
+
local d; d="$(mk_logs_dir)"
|
|
119
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
120
|
+
mkdir "$d/.rotate.lock"
|
|
121
|
+
backdate "$d/.rotate.lock" 1 # 1 day > STALE_LOCK_MINUTES=30
|
|
122
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
123
|
+
[ -f "$d/server.log.1.gz" ] || { echo " stale lock was not reclaimed"; rm -rf "$d"; return 1; }
|
|
124
|
+
rm -rf "$d"; return 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case_missing_logs_dir_is_noop_exit_zero() {
|
|
128
|
+
"$ROTATE" "/nonexistent/logs/dir/for/test" >/dev/null 2>&1
|
|
129
|
+
local rc=$?
|
|
130
|
+
[ "$rc" -eq 0 ] || { echo " expected exit 0 on missing dir, got $rc"; return 1; }
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case_lock_released_on_exit() {
|
|
135
|
+
local d; d="$(mk_logs_dir)"
|
|
136
|
+
mk_file_of_size "$d/server.log" $((1 * 1024 * 1024))
|
|
137
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
138
|
+
[ ! -e "$d/.rotate.lock" ] || { echo " lock not released"; rm -rf "$d"; return 1; }
|
|
139
|
+
rm -rf "$d"; return 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case_purges_rc_child_older_than_seven_days() {
|
|
143
|
+
local d; d="$(mk_logs_dir)"
|
|
144
|
+
echo old > "$d/rc-child-aaaa.log"; backdate "$d/rc-child-aaaa.log" 9
|
|
145
|
+
echo new > "$d/rc-child-bbbb.log" # today
|
|
146
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
147
|
+
[ ! -e "$d/rc-child-aaaa.log" ] || { echo " 9-day-old rc-child survived"; rm -rf "$d"; return 1; }
|
|
148
|
+
[ -f "$d/rc-child-bbbb.log" ] || { echo " today's rc-child was purged"; rm -rf "$d"; return 1; }
|
|
149
|
+
rm -rf "$d"; return 0
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case_purge_does_not_touch_server_log() {
|
|
153
|
+
local d; d="$(mk_logs_dir)"
|
|
154
|
+
echo old > "$d/server.log"; backdate "$d/server.log" 90
|
|
155
|
+
echo x | gzip -c > "$d/server.log.1.gz"; backdate "$d/server.log.1.gz" 90
|
|
156
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
157
|
+
[ -f "$d/server.log" ] || { echo " server.log was purged by age"; rm -rf "$d"; return 1; }
|
|
158
|
+
[ -f "$d/server.log.1.gz" ] || { echo " a kept generation was purged by age"; rm -rf "$d"; return 1; }
|
|
159
|
+
rm -rf "$d"; return 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case_mcp_stderr_live_file_size_rotates() {
|
|
163
|
+
local d; d="$(mk_logs_dir)"
|
|
164
|
+
mk_file_of_size "$d/mcp-memory-stderr-2026-07-17.log" $((65 * 1024 * 1024))
|
|
165
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
166
|
+
[ -f "$d/mcp-memory-stderr-2026-07-17.log.1.gz" ] \
|
|
167
|
+
|| { echo " live mcp stderr log was not size-rotated"; rm -rf "$d"; return 1; }
|
|
168
|
+
rm -rf "$d"; return 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# --- Code-review regressions (2026-07-17) -----------------------------------
|
|
172
|
+
|
|
173
|
+
# A failing gzip must not cost a generation. The shift used to run BEFORE the
|
|
174
|
+
# gzip, so every failure moved .4->.5 ... .1->.2 and wrote nothing to .1 —
|
|
175
|
+
# leaving a hole and dropping the oldest. The trigger is ENOSPC, i.e. exactly
|
|
176
|
+
# the condition rotation exists to prevent: 5 failing ticks destroyed 4 of the
|
|
177
|
+
# 5 retained generations while never truncating the live file.
|
|
178
|
+
case_gzip_failure_preserves_generations() {
|
|
179
|
+
local d; d="$(mk_logs_dir)"
|
|
180
|
+
local bin="$d/../bin-$$"; mkdir -p "$bin"
|
|
181
|
+
printf '#!/bin/sh\nexit 1\n' > "$bin/gzip"; chmod +x "$bin/gzip"
|
|
182
|
+
local i
|
|
183
|
+
for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
|
|
184
|
+
mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
|
|
185
|
+
|
|
186
|
+
PATH="$bin:$PATH" "$ROTATE" "$d" >/dev/null 2>&1
|
|
187
|
+
local rc=$?
|
|
188
|
+
|
|
189
|
+
local fail=0
|
|
190
|
+
for i in 1 2 3 4 5; do
|
|
191
|
+
if [ "$(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)" != "gen$i" ]; then
|
|
192
|
+
echo " .$i.gz should still be gen$i, got: $(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)"; fail=1
|
|
193
|
+
fi
|
|
194
|
+
done
|
|
195
|
+
# The live file must be untouched — a failed rotate never truncates.
|
|
196
|
+
[ "$(size_of "$d/server.log")" -eq $((65 * 1024 * 1024)) ] \
|
|
197
|
+
|| { echo " live file was truncated despite gzip failure"; fail=1; }
|
|
198
|
+
[ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; fail=1; }
|
|
199
|
+
[ ! -e "$d/server.log.1.gz.tmp" ] || { echo " temp file left behind"; fail=1; }
|
|
200
|
+
rm -rf "$d" "$bin"; return $fail
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# The mcp stderr tee opens its file at process start and holds the stream for the
|
|
204
|
+
# process's whole life, writing ONLY when the server emits stderr — which a
|
|
205
|
+
# healthy server does not do for days. So a live file's mtime DOES go stale, and
|
|
206
|
+
# deleting it sends every later stderr write to an unlinked inode: invisible
|
|
207
|
+
# exactly when the fault you are chasing finally appears. Never purge these.
|
|
208
|
+
case_never_purges_mcp_stderr_by_age() {
|
|
209
|
+
local d; d="$(mk_logs_dir)"
|
|
210
|
+
echo quiet > "$d/mcp-memory-stderr-2026-01-01.log"
|
|
211
|
+
backdate "$d/mcp-memory-stderr-2026-01-01.log" 90
|
|
212
|
+
echo x | gzip -c > "$d/mcp-memory-stderr-2026-01-01.log.1.gz"
|
|
213
|
+
backdate "$d/mcp-memory-stderr-2026-01-01.log.1.gz" 90
|
|
214
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
215
|
+
[ -f "$d/mcp-memory-stderr-2026-01-01.log" ] \
|
|
216
|
+
|| { echo " a 90-day-stale mcp stderr log was purged — it may still be held open"; rm -rf "$d"; return 1; }
|
|
217
|
+
[ -f "$d/mcp-memory-stderr-2026-01-01.log.1.gz" ] \
|
|
218
|
+
|| { echo " an mcp generation was purged by age"; rm -rf "$d"; return 1; }
|
|
219
|
+
rm -rf "$d"; return 0
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# rc-child's 16 MiB cap does NOT hold: `written` is in-memory per manager
|
|
223
|
+
# process, so a manager restart resumes appending to the same path. Observed at
|
|
224
|
+
# 54 MB on sitedesk-code (3.25x the cap). A live long-lived session's file has a
|
|
225
|
+
# fresh mtime forever, so age-purge never reaches it — it needs size rotation.
|
|
226
|
+
case_rc_child_size_rotates() {
|
|
227
|
+
local d; d="$(mk_logs_dir)"
|
|
228
|
+
mk_file_of_size "$d/rc-child-live-session.log" $((65 * 1024 * 1024))
|
|
229
|
+
"$ROTATE" "$d" >/dev/null 2>&1
|
|
230
|
+
[ -f "$d/rc-child-live-session.log.1.gz" ] \
|
|
231
|
+
|| { echo " an oversized rc-child log was not size-rotated"; rm -rf "$d"; return 1; }
|
|
232
|
+
local live; live="$(size_of "$d/rc-child-live-session.log")"
|
|
233
|
+
[ "$live" -lt 4096 ] || { echo " rc-child live file not truncated: $live"; rm -rf "$d"; return 1; }
|
|
234
|
+
rm -rf "$d"; return 0
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
run_case "gzip failure preserves generations" case_gzip_failure_preserves_generations
|
|
238
|
+
run_case "never purges mcp stderr by age" case_never_purges_mcp_stderr_by_age
|
|
239
|
+
run_case "rc-child size-rotates" case_rc_child_size_rotates
|
|
240
|
+
|
|
241
|
+
run_case "under threshold is a no-op" case_under_threshold_is_noop
|
|
242
|
+
run_case "over threshold rotates and truncates" case_over_threshold_rotates_and_truncates
|
|
243
|
+
run_case "marker line present and timestamped" case_marker_line_present
|
|
244
|
+
run_case "generation shift drops the sixth" case_generation_shift_drops_the_sixth
|
|
245
|
+
run_case "lock held is a no-op, exit 0" case_lock_held_is_noop_exit_zero
|
|
246
|
+
run_case "stale lock is reclaimed" case_stale_lock_is_reclaimed
|
|
247
|
+
run_case "missing logs dir is a no-op, exit 0" case_missing_logs_dir_is_noop_exit_zero
|
|
248
|
+
run_case "lock released on exit" case_lock_released_on_exit
|
|
249
|
+
run_case "purges rc-child older than 7 days" case_purges_rc_child_older_than_seven_days
|
|
250
|
+
run_case "purge does not touch server.log" case_purge_does_not_touch_server_log
|
|
251
|
+
run_case "mcp stderr live file size-rotates" case_mcp_stderr_live_file_size_rotates
|
|
252
|
+
|
|
253
|
+
echo ""
|
|
254
|
+
echo "===================="
|
|
255
|
+
echo "PASS: $PASS FAIL: $FAIL"
|
|
256
|
+
[ "$FAIL" -eq 0 ]
|