@steve31415/baselib 3.29.0 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,9 @@ ERROR log (`~/plasticine-way/docs/SECURITY.md`, "Resend API key").
29
29
 
30
30
  Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
31
31
  app runs it from `npm run verify`. `pw-deploy` / `pw-rollback` — the fleet
32
- deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
32
+ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety"); an
33
+ app's hooks module (`DeployHooks` in `src/deploy/types.ts`) extends both,
34
+ `postRollback` being the one hook `pw-rollback` runs.
33
35
  `pw-sql` / `pw-logs` — read-only production queries (database, Axiom), the
34
36
  first step of any production investigation (same doc, "Investigating
35
37
  production state").
@@ -7,7 +7,7 @@ import { hostname } from 'node:os';
7
7
  import { must, mustJson } from './exec.js';
8
8
  import { openEvidence, PhaseTimer } from './evidence.js';
9
9
  import { acquireLock, listReleaseRecords, placeHold, releaseLock } from './gcs.js';
10
- import { DEFAULT_ACCOUNT, loadConfig } from './config.js';
10
+ import { DEFAULT_ACCOUNT, loadConfig, loadHooks } from './config.js';
11
11
  import { pickRevisionForSha, releaseEntriesFrom, selectRollbackTarget, readServingState, SHA40, } from './plan.js';
12
12
  export async function runRollback(options) {
13
13
  const log = options.log ?? ((m) => console.error(`[pw-rollback] ${m}`));
@@ -19,6 +19,7 @@ export async function runRollback(options) {
19
19
  const requested = argv.find((a) => SHA40.test(a));
20
20
  const timer = new PhaseTimer(log);
21
21
  let evidenceDir = '';
22
+ let rolledBackTo = '';
22
23
  let lock = null;
23
24
  try {
24
25
  const config = await loadConfig(options.repoRoot);
@@ -151,6 +152,26 @@ export async function runRollback(options) {
151
152
  throw new Error(`${service.name}: post-rollback smoke failed (${lastDetail})`);
152
153
  log(`${service.name}: smoke passed`);
153
154
  }
155
+ // The hooks module is loaded only now: a broken one must not stand
156
+ // between an incident and the traffic move.
157
+ timer.enter('hooks');
158
+ rolledBackTo = target;
159
+ const hooks = await loadHooks(options.repoRoot, config);
160
+ if (hooks.postRollback) {
161
+ const ctx = {
162
+ sha: target,
163
+ baseSha: servingShas.length === 1 ? servingShas[0] : null,
164
+ config,
165
+ repoRoot: options.repoRoot,
166
+ contextDir: '',
167
+ evidenceDir,
168
+ imageDigest: targets[0].imageDigest,
169
+ serving,
170
+ exec: runner,
171
+ log,
172
+ };
173
+ await hooks.postRollback(ctx);
174
+ }
154
175
  timer.close();
155
176
  log(`rolled back to ${target.slice(0, 12)}`);
156
177
  log(`hold placed on ${servingShas.map((sha) => sha.slice(0, 12)).join(', ')} — ` +
@@ -162,6 +183,10 @@ export async function runRollback(options) {
162
183
  const message = error instanceof Error ? error.message : String(error);
163
184
  console.error(message);
164
185
  console.error(`rollback failed in phase: ${timer.current}`);
186
+ if (rolledBackTo) {
187
+ console.error(`traffic IS on ${rolledBackTo.slice(0, 12)} and the hold is placed; only the app's ` +
188
+ 'postRollback hook failed — finish what it does by hand');
189
+ }
165
190
  if (evidenceDir)
166
191
  console.error(`evidence: ${evidenceDir}`);
167
192
  return 1;
@@ -120,4 +120,11 @@ export interface DeployHooks {
120
120
  smoke?: (ctx: HookContext) => Promise<void>;
121
121
  /** Runs after everything else succeeded (e.g. notes's release state). */
122
122
  postSuccess?: (ctx: HookContext) => Promise<void>;
123
+ /** pw-rollback only: runs after traffic is back on the target build, the
124
+ * hold is placed and the smoke passed — for what a traffic move does not
125
+ * carry (e.g. podcasts' render Job image). `sha` and `imageDigest` are the
126
+ * rollback target's, `serving` is the state rolled back from, and
127
+ * `contextDir` is empty (nothing is built). A throw fails the command but
128
+ * undoes nothing. */
129
+ postRollback?: (ctx: HookContext) => Promise<void>;
123
130
  }
@@ -13,32 +13,35 @@ function classifyFinish(stopReason) {
13
13
  }
14
14
  }
15
15
  /**
16
- * Keywords `output_config.format` rejects outright with a 400 (verified on
17
- * Opus 5.5, 2026-09-22). `minItems` is also rejected for values other than 0
18
- * or 1, handled separately; `minLength`/`maxLength`, `pattern` and `format`
19
- * are accepted.
16
+ * Keywords Anthropic's constrained decoding rejects outright with a 400 —
17
+ * `output_config.format` (verified on Opus 5.5, 2026-09-22) and strict tools
18
+ * alike (verified on every forced-tool registry model, 2026-10-07). `minItems`
19
+ * is also rejected for values other than 0 or 1, handled separately;
20
+ * `minLength`/`maxLength`, `pattern`, `format` and nullable unions are
21
+ * accepted.
20
22
  */
21
- const UNSUPPORTED_FORMAT_KEYWORDS = [
23
+ const UNSUPPORTED_STRICT_KEYWORDS = [
22
24
  'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
23
25
  'maxItems', 'uniqueItems',
24
26
  ];
25
27
  /**
26
- * Transform a portable JSON schema into the dialect Anthropic's structured
27
- * outputs (`output_config.format`) accepts: every object schema must set
28
- * `additionalProperties: false`, and the numeric/array bounds listed above are
29
- * dropped. The model then no longer enforces those bounds — callers validate
30
- * the parsed result as they already must for truncation. Unlike forced
31
- * tool_use, optional properties stay optional. Returns a deep copy — the
32
- * caller's schema object is reused across failover attempts.
28
+ * Transform a portable JSON schema into the dialect Anthropic's constrained
29
+ * decoding accepts (`output_config.format` and `strict: true` tools): every
30
+ * object schema must set `additionalProperties: false`, and the numeric/array
31
+ * bounds listed above are dropped. The model then no longer enforces those
32
+ * bounds — callers validate the parsed result as they already must for
33
+ * truncation. Optional properties stay optional (unlike OpenAI's strict
34
+ * dialect). Returns a deep copy — the caller's schema object is reused across
35
+ * failover attempts.
33
36
  */
34
- function toOutputFormatSchema(node) {
37
+ function toAnthropicStrictSchema(node) {
35
38
  if (Array.isArray(node))
36
- return node.map(toOutputFormatSchema);
39
+ return node.map(toAnthropicStrictSchema);
37
40
  if (node === null || typeof node !== 'object')
38
41
  return node;
39
42
  const out = {};
40
43
  for (const [key, value] of Object.entries(node)) {
41
- if (UNSUPPORTED_FORMAT_KEYWORDS.includes(key))
44
+ if (UNSUPPORTED_STRICT_KEYWORDS.includes(key))
42
45
  continue;
43
46
  if (key === 'minItems' && typeof value === 'number' && value > 1)
44
47
  continue;
@@ -46,10 +49,10 @@ function toOutputFormatSchema(node) {
46
49
  // property that happens to be named `minimum` is kept.
47
50
  if ((key === 'properties' || key === '$defs' || key === 'definitions')
48
51
  && value !== null && typeof value === 'object' && !Array.isArray(value)) {
49
- out[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toOutputFormatSchema(v)]));
52
+ out[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toAnthropicStrictSchema(v)]));
50
53
  continue;
51
54
  }
52
- out[key] = toOutputFormatSchema(value);
55
+ out[key] = toAnthropicStrictSchema(value);
53
56
  }
54
57
  const type = out.type;
55
58
  if (type === 'object' || (Array.isArray(type) && type.includes('object'))) {
@@ -87,20 +90,25 @@ export const anthropicProvider = {
87
90
  ? [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }]
88
91
  : request.systemPrompt;
89
92
  }
90
- // JSON schema: a forced tool_use call where the model allows it, else
91
- // structured outputs (Opus 5.5 rejects forced tool_choice).
93
+ // JSON schema: a forced, strict tool_use call where the model allows it,
94
+ // else structured outputs (Opus 5.5 rejects forced tool_choice). Both
95
+ // constrain decoding to the schema. A tool without `strict: true` is not
96
+ // enforced: Sonnet 5 returned an array property as a JSON string (digest,
97
+ // 2026-10-06), Opus 4.8 one wrapped in leaked tool-call markup (podcasts,
98
+ // 2026-09-15).
92
99
  const useOutputFormat = request.jsonSchema !== undefined && !request.supportsForcedToolChoice;
93
100
  if (request.jsonSchema && useOutputFormat) {
94
101
  body.output_config = {
95
102
  ...body.output_config,
96
- format: { type: 'json_schema', schema: toOutputFormatSchema(request.jsonSchema.schema) },
103
+ format: { type: 'json_schema', schema: toAnthropicStrictSchema(request.jsonSchema.schema) },
97
104
  };
98
105
  }
99
106
  else if (request.jsonSchema) {
100
107
  body.tools = [{
101
108
  name: request.jsonSchema.name,
102
109
  description: `Generate structured output matching the ${request.jsonSchema.name} schema`,
103
- input_schema: request.jsonSchema.schema,
110
+ input_schema: toAnthropicStrictSchema(request.jsonSchema.schema),
111
+ strict: true,
104
112
  }];
105
113
  body.tool_choice = { type: 'tool', name: request.jsonSchema.name };
106
114
  }
@@ -37,10 +37,10 @@ function makeNullable(propSchema) {
37
37
  * Transform a portable JSON schema into the shape OpenAI's strict Structured
38
38
  * Outputs mode requires: every object must set `additionalProperties: false`
39
39
  * and list ALL of its properties in `required`. Callers author one portable
40
- * schema (which also works on Anthropic's tool_use and Gemini's responseSchema,
41
- * both of which tolerate missing `additionalProperties` and optional fields);
42
- * this keeps the OpenAI-specific dialect local to this adapter, mirroring the
43
- * Gemini adapter's own `stripUnsupportedSchemaFields`. Returns a deep copy — the
40
+ * schema; this keeps the OpenAI-specific dialect local to this adapter,
41
+ * mirroring the Gemini adapter's `stripUnsupportedSchemaFields` and the
42
+ * Anthropic adapter's `toAnthropicStrictSchema` (which closes objects but keeps
43
+ * optional fields optional). Returns a deep copy — the
44
44
  * caller's schema object is never mutated (it is reused across failover attempts).
45
45
  */
46
46
  function toStrictSchema(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.29.0",
3
+ "version": "3.31.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",