siluzan-tso-cli 1.1.15-beta.1 → 1.1.15-beta.2

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
@@ -51,7 +51,7 @@ siluzan-tso init -d /path/to/skills # 写入自定义目录
51
51
  siluzan-tso init --force # 强制覆盖已存在文件
52
52
  ```
53
53
 
54
- > **注意**:当前为测试版(1.1.15-beta.1),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
54
+ > **注意**:当前为测试版(1.1.15-beta.2),供内部测试使用。正式发布后安装命令将改为 `npm install -g siluzan-tso-cli`。
55
55
 
56
56
  | 助手 | 建议 `--ai` |
57
57
  | ----------------------- | ------------------------------------ |
package/dist/index.js CHANGED
@@ -2760,6 +2760,8 @@ async function fetchLatestVersion() {
2760
2760
  import * as path5 from "path";
2761
2761
  import { mkdir as mkdir2, writeFile, chmod as chmod2 } from "fs/promises";
2762
2762
  var MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024;
2763
+ var CAMPAIGN_DETAIL_RE = /^\/campaignmanagement\/campaign\/[^/]+\/[^/]+\/?$/i;
2764
+ var SEM_MANAGEMENT_RE = /\/SemManagement\//i;
2763
2765
  function shouldAttemptPreSnapshot(method, url) {
2764
2766
  const m = method.toUpperCase();
2765
2767
  if (m !== "PUT" && m !== "PATCH") return false;
@@ -2771,7 +2773,9 @@ function shouldAttemptPreSnapshot(method, url) {
2771
2773
  }
2772
2774
  const host = u.hostname.toLowerCase();
2773
2775
  if (!host.includes("googleapi")) return false;
2774
- return /\/SemManagement\//i.test(u.pathname);
2776
+ if (SEM_MANAGEMENT_RE.test(u.pathname)) return true;
2777
+ if (CAMPAIGN_DETAIL_RE.test(u.pathname)) return true;
2778
+ return false;
2775
2779
  }
2776
2780
  function getSnapshotGetUrl(method, url) {
2777
2781
  if (!shouldAttemptPreSnapshot(method, url)) return null;
@@ -3671,6 +3675,10 @@ function applyListFilters(records, opts) {
3671
3675
  });
3672
3676
  }
3673
3677
  }
3678
+ const rk = opts.resourceKey?.trim();
3679
+ if (rk) {
3680
+ out = out.filter((r) => r.resourceKey === rk);
3681
+ }
3674
3682
  return out;
3675
3683
  }
3676
3684
  async function runAuditList(opts) {
@@ -3690,6 +3698,7 @@ async function runAuditList(opts) {
3690
3698
  outcome: opts.outcome,
3691
3699
  runId: opts.runId,
3692
3700
  sinceTs: opts.sinceTs,
3701
+ resourceKey: opts.resourceKey,
3693
3702
  failuresOnly: Boolean(opts.failuresOnly)
3694
3703
  },
3695
3704
  items: filtered
@@ -3809,77 +3818,178 @@ auditId: ${record.auditId ?? "\u2014"}`);
3809
3818
  // src/commands/audit-restore.ts
3810
3819
  import { createHash } from "crypto";
3811
3820
  var TSO_WRITE_AUDIT_NS3 = "tso";
3821
+ var SUBSEQUENT_WRITES_LOOKBACK_DAYS_MAX = 400;
3812
3822
  async function loadSnapshotForRecord(record) {
3813
3823
  if (!record.preSnapshotRef) return null;
3814
3824
  return readPreSnapshotPayload(TSO_WRITE_AUDIT_NS3, record.preSnapshotRef);
3815
3825
  }
3816
- async function runAuditRestorePlan(opts) {
3817
- const auditId = opts.id?.trim() ?? "";
3818
- if (!auditId) {
3819
- console.error("\u8BF7\u4F7F\u7528 --id <auditId>");
3820
- process.exit(1);
3826
+ function beijingDayFromIsoTs(ts) {
3827
+ const d = new Date(ts);
3828
+ if (Number.isNaN(d.getTime())) return formatBeijingYmd(/* @__PURE__ */ new Date());
3829
+ return formatBeijingYmd(d);
3830
+ }
3831
+ async function collectSubsequentWrites(target) {
3832
+ const key = target.resourceKey?.trim();
3833
+ if (!key) return [];
3834
+ const sinceDay = beijingDayFromIsoTs(target.ts);
3835
+ const safeSince = sinceDay && /^\d{4}-\d{2}-\d{2}$/.test(sinceDay) ? sinceDay : beijingDayStringDaysAgo(SUBSEQUENT_WRITES_LOOKBACK_DAYS_MAX);
3836
+ const records = await readWriteAuditRecordsSince(TSO_WRITE_AUDIT_NS3, safeSince);
3837
+ const targetMs = new Date(target.ts).getTime();
3838
+ return records.filter((r) => {
3839
+ if (r.resourceKey !== key) return false;
3840
+ if (r.auditId === target.auditId) return false;
3841
+ const t = new Date(r.ts).getTime();
3842
+ if (Number.isNaN(t) || Number.isNaN(targetMs)) return false;
3843
+ return t > targetMs;
3844
+ }).map((r) => ({
3845
+ auditId: r.auditId ?? "",
3846
+ ts: r.ts,
3847
+ method: r.method,
3848
+ outcome: r.outcome,
3849
+ pathname: r.pathname,
3850
+ commit: r.commit,
3851
+ invokedCommand: r.invokedCommand,
3852
+ hasSnapshot: Boolean(r.preSnapshotRef)
3853
+ })).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
3854
+ }
3855
+ function deriveSupportability(record, snapshot) {
3856
+ if (!record.auditId) {
3857
+ return {
3858
+ code: "v1_record_no_audit_id",
3859
+ reason: "v1 \u5386\u53F2\u5BA1\u8BA1\u884C\u7F3A auditId/preSnapshotRef\uFF0C\u65E0\u6CD5\u7528 restore-apply",
3860
+ hint: "\u8BF7\u4EBA\u5DE5\u6839\u636E commit \u6587\u672C\u4E0E\u539F\u59CB\u53D8\u66F4\u4EBA\u5DE5\u6062\u590D\uFF1B\u6216\u4EC5\u4F5C\u4E3A\u53C2\u8003\u67E5\u770B audit show"
3861
+ };
3862
+ }
3863
+ if (record.outcome !== "success") {
3864
+ return {
3865
+ code: "not_success_write",
3866
+ reason: `audit \u7ED3\u679C\u4E3A ${record.outcome}\uFF0C\u539F\u5199\u672A\u751F\u6548\uFF0C\u65E0\u9700\u8865\u507F`,
3867
+ hint: "\u5931\u8D25\u7684\u5199\u4E0D\u4F1A\u6539\u53D8\u7EBF\u4E0A\u72B6\u6001\uFF1B\u5982\u9700\u6392\u67E5\u8BF7\u770B audit show \u4E2D\u7684 error \u5B57\u6BB5"
3868
+ };
3869
+ }
3870
+ const method = record.method.toUpperCase();
3871
+ if (method === "POST") {
3872
+ return {
3873
+ code: "unsupported_method_post",
3874
+ reason: "POST \u521B\u5EFA\u578B\u5199\u65E0\u5199\u524D\u5FEB\u7167\uFF0C\u65E0\u6CD5\u7528\u540C URL \u8865\u507F",
3875
+ hint: "POST \u64A4\u9500\u9700\u8C03\u7528\u5BF9\u5E94\u8D44\u6E90\u7684 DELETE \u7AEF\u70B9\uFF08CLI \u6682\u672A\u81EA\u52A8\u6D3E\u751F\uFF09\uFF1B\u8BF7\u4EBA\u5DE5\u8FD0\u884C\u5BF9\u5E94 delete/stop \u547D\u4EE4"
3876
+ };
3877
+ }
3878
+ if (method === "DELETE") {
3879
+ return {
3880
+ code: "unsupported_method_delete",
3881
+ reason: "DELETE \u5220\u9664\u578B\u5199\u5728\u5F53\u524D\u5FEB\u7167\u767D\u540D\u5355\u5916\uFF0C\u65E0\u5199\u524D GET",
3882
+ hint: "DELETE \u64A4\u9500\u9700\u6839\u636E\u5386\u53F2\u5FEB\u7167\u91CD\u5EFA\u8D44\u6E90\uFF1B\u5F53\u524D CLI \u672A\u5BF9\u8BE5\u8DEF\u5F84\u914D\u7F6E\u5199\u524D\u5FEB\u7167\uFF0C\u8BF7\u4EBA\u5DE5\u6062\u590D"
3883
+ };
3884
+ }
3885
+ if (method !== "PUT" && method !== "PATCH") {
3886
+ return {
3887
+ code: "unsupported_method_other",
3888
+ reason: `\u8865\u507F\u5199\u4EC5\u652F\u6301 PUT/PATCH\uFF0C\u672C\u6761\u4E3A ${method}`
3889
+ };
3890
+ }
3891
+ if (!record.preSnapshotRef) {
3892
+ return {
3893
+ code: "no_snapshot",
3894
+ reason: "\u8BE5 PUT/PATCH \u672A\u547D\u4E2D\u5199\u524D\u5FEB\u7167\u767D\u540D\u5355\uFF08\u4EC5 Google SemManagement \u9ED8\u8BA4\u5F00\u542F\uFF09",
3895
+ hint: "\u65E0\u6CD5\u81EA\u52A8\u8865\u507F\uFF1B\u8BF7\u4EBA\u5DE5 GET \u5386\u53F2\u503C\u540E\u901A\u8FC7\u5E38\u89C4 ad/optimize \u547D\u4EE4\u53CD\u5411\u5199"
3896
+ };
3897
+ }
3898
+ if (!snapshot) {
3899
+ return {
3900
+ code: "snapshot_missing_or_invalid",
3901
+ reason: "preSnapshotRef \u6307\u5411\u7684 snapshot JSON \u4E0D\u53EF\u8BFB\u6216\u8D8A\u754C",
3902
+ hint: "\u68C0\u67E5 ~/.siluzan/write-audit/tso/snapshots/<auditId>.json \u662F\u5426\u5B58\u5728\uFF1B\u6216\u4FDD\u7559\u671F\u5DF2\u8FC7\u88AB\u6E05\u7406"
3903
+ };
3821
3904
  }
3905
+ return {
3906
+ code: "ready_put_patch",
3907
+ reason: "PUT/PATCH \u5355\u6B65\u8865\u507F\u5199\u5C31\u7EEA"
3908
+ };
3909
+ }
3910
+ async function buildRestoreContext(auditId) {
3822
3911
  const record = await findWriteAuditRecordByAuditId(TSO_WRITE_AUDIT_NS3, auditId);
3823
- let plan;
3824
3912
  if (!record) {
3825
- plan = { status: "unsupported", auditId, reason: "audit_not_found" };
3826
- } else if (record.outcome !== "success") {
3827
- plan = { status: "unsupported", auditId, reason: "not_success_write", detail: record.outcome, record };
3828
- } else if (!record.preSnapshotRef) {
3829
- plan = {
3830
- status: "unsupported",
3831
- auditId,
3832
- reason: "no_snapshot",
3833
- detail: "\u7F3A\u5C11 preSnapshotRef",
3834
- record
3913
+ return {
3914
+ record: null,
3915
+ snapshot: null,
3916
+ subsequentWrites: [],
3917
+ successfulSubsequentWriteCount: 0,
3918
+ supportability: {
3919
+ code: "audit_not_found",
3920
+ reason: `\u672C\u673A\u6700\u8FD1 ${SUBSEQUENT_WRITES_LOOKBACK_DAYS_MAX} \u4E2A\u5317\u4EAC\u65E5\u5185\u672A\u627E\u5230 auditId=${auditId}`,
3921
+ hint: "\u7528 audit list --days 14 --match <commit/path \u7247\u6BB5> \u53CD\u67E5\uFF1B\u8D85\u8FC7\u4FDD\u7559\u671F\u53EF\u80FD\u5DF2\u88AB\u6E05\u7406"
3922
+ }
3835
3923
  };
3836
- } else {
3837
- const snap = await loadSnapshotForRecord(record);
3838
- if (!snap) {
3839
- plan = {
3840
- status: "unsupported",
3841
- auditId,
3842
- reason: "snapshot_missing_or_invalid",
3843
- record
3924
+ }
3925
+ const snapshot = await loadSnapshotForRecord(record);
3926
+ const subsequentWrites = await collectSubsequentWrites(record);
3927
+ const successfulSubsequentWriteCount = subsequentWrites.filter(
3928
+ (w) => w.outcome === "success"
3929
+ ).length;
3930
+ return {
3931
+ record,
3932
+ snapshot,
3933
+ resourceKey: record.resourceKey,
3934
+ subsequentWrites,
3935
+ successfulSubsequentWriteCount,
3936
+ supportability: deriveSupportability(record, snapshot)
3937
+ };
3938
+ }
3939
+ function ctxToPlanPayload(auditId, ctx) {
3940
+ const { record, snapshot, subsequentWrites, successfulSubsequentWriteCount, supportability } = ctx;
3941
+ const target = record ? {
3942
+ ts: record.ts,
3943
+ pathname: record.pathname,
3944
+ method: record.method,
3945
+ outcome: record.outcome,
3946
+ commit: record.commit,
3947
+ invokedCommand: record.invokedCommand,
3948
+ httpStatus: record.httpStatus
3949
+ } : null;
3950
+ const snapshotSummary = snapshot ? (() => {
3951
+ const bodyJson = JSON.stringify(snapshot.body);
3952
+ return {
3953
+ capturedAt: snapshot.capturedAt,
3954
+ originalMethod: snapshot.originalMethod,
3955
+ originalUrl: snapshot.originalUrl,
3956
+ bodyUtf8Length: Buffer.byteLength(bodyJson, "utf8"),
3957
+ bodySha256Prefix: createHash("sha256").update(bodyJson).digest("hex").slice(0, 16)
3958
+ };
3959
+ })() : null;
3960
+ const steps = supportability.code === "ready_put_patch" && snapshot ? [
3961
+ (() => {
3962
+ const bodyJson = JSON.stringify(snapshot.body);
3963
+ return {
3964
+ order: 1,
3965
+ method: snapshot.originalMethod.toUpperCase(),
3966
+ url: snapshot.originalUrl,
3967
+ body: snapshot.body,
3968
+ bodyUtf8Length: Buffer.byteLength(bodyJson, "utf8"),
3969
+ bodySha256Prefix: createHash("sha256").update(bodyJson).digest("hex").slice(0, 16)
3844
3970
  };
3845
- } else {
3846
- const m = snap.originalMethod.toUpperCase();
3847
- if (m !== "PUT" && m !== "PATCH") {
3848
- plan = {
3849
- status: "unsupported",
3850
- auditId,
3851
- reason: "restore_only_put_patch",
3852
- detail: m,
3853
- record
3854
- };
3855
- } else {
3856
- const bodyJson = JSON.stringify(snap.body);
3857
- const hash = createHash("sha256").update(bodyJson).digest("hex").slice(0, 16);
3858
- plan = {
3859
- status: "ready",
3860
- willMutate: false,
3861
- auditId,
3862
- record: {
3863
- ts: record.ts,
3864
- pathname: record.pathname,
3865
- commit: record.commit ?? "",
3866
- method: record.method,
3867
- outcome: record.outcome
3868
- },
3869
- steps: [
3870
- {
3871
- order: 1,
3872
- method: m,
3873
- url: snap.originalUrl,
3874
- body: snap.body,
3875
- bodyUtf8Length: Buffer.byteLength(bodyJson, "utf8"),
3876
- bodySha256Prefix: hash
3877
- }
3878
- ]
3879
- };
3880
- }
3881
- }
3971
+ })()
3972
+ ] : [];
3973
+ return {
3974
+ auditId,
3975
+ resourceKey: ctx.resourceKey,
3976
+ supportability,
3977
+ willMutate: supportability.code === "ready_put_patch",
3978
+ target,
3979
+ snapshot: snapshotSummary,
3980
+ subsequentWrites,
3981
+ guardChecks: { ackSubsequentWrites: successfulSubsequentWriteCount },
3982
+ steps
3983
+ };
3984
+ }
3985
+ async function runAuditRestorePlan(opts) {
3986
+ const auditId = opts.id?.trim() ?? "";
3987
+ if (!auditId) {
3988
+ console.error("\u8BF7\u4F7F\u7528 --id <auditId>");
3989
+ process.exit(1);
3882
3990
  }
3991
+ const ctx = await buildRestoreContext(auditId);
3992
+ const plan = ctxToPlanPayload(auditId, ctx);
3883
3993
  const envelope = {
3884
3994
  auditDir: getWriteAuditRootDir(TSO_WRITE_AUDIT_NS3),
3885
3995
  plan
@@ -3890,15 +4000,62 @@ async function runAuditRestorePlan(opts) {
3890
4000
  )) {
3891
4001
  return;
3892
4002
  }
3893
- if (plan.status === "ready") {
3894
- console.log("\n\u6062\u590D\u8BA1\u5212\uFF08\u4EC5\u5C55\u793A\uFF0C\u672A\u4FEE\u6539\u7EBF\u4E0A\u8D44\u6E90\uFF09\uFF1A");
3895
- console.log(JSON.stringify(envelope, null, 2));
4003
+ printPlanForHuman(envelope);
4004
+ }
4005
+ function printPlanForHuman(envelope) {
4006
+ const { plan } = envelope;
4007
+ console.log(`
4008
+ [restore-plan] auditDir: ${envelope.auditDir}`);
4009
+ console.log(`auditId: ${plan.auditId}`);
4010
+ if (plan.resourceKey) console.log(`resourceKey: ${plan.resourceKey}`);
4011
+ console.log(`supportability: ${plan.supportability.code} \u2014\u2014 ${plan.supportability.reason}`);
4012
+ if (plan.supportability.hint) console.log(` hint: ${plan.supportability.hint}`);
4013
+ if (plan.target) {
4014
+ console.log(
4015
+ `
4016
+ \u76EE\u6807 audit\uFF1A${plan.target.ts} ${plan.target.method} ${plan.target.pathname} outcome=${plan.target.outcome}` + (plan.target.commit ? `
4017
+ commit: ${plan.target.commit}` : "")
4018
+ );
4019
+ }
4020
+ if (plan.snapshot) {
4021
+ console.log(
4022
+ `
4023
+ \u5199\u524D\u5FEB\u7167\uFF1AcapturedAt=${plan.snapshot.capturedAt} originalMethod=${plan.snapshot.originalMethod}
4024
+ url: ${plan.snapshot.originalUrl}
4025
+ body: ${plan.snapshot.bodyUtf8Length} bytes sha256[0..16]=${plan.snapshot.bodySha256Prefix}`
4026
+ );
4027
+ }
4028
+ console.log(
4029
+ `
4030
+ \u540C\u8D44\u6E90\u540E\u7EED\u5199\uFF08target.ts \u4E4B\u540E\uFF0C\u542B\u5931\u8D25\uFF09\uFF1A${plan.subsequentWrites.length} \u6761\uFF1B\u5176\u4E2D\u6210\u529F ${plan.guardChecks.ackSubsequentWrites} \u6761`
4031
+ );
4032
+ if (plan.subsequentWrites.length > 0) {
4033
+ console.log(
4034
+ ` \u26A0\uFE0F \u6267\u884C restore-apply \u4F1A\u628A snapshot.body \u6574\u4E2A PUT/PATCH \u56DE\u53BB\uFF0C\u6210\u529F\u7684 ${plan.guardChecks.ackSubsequentWrites} \u6761\u540E\u7EED\u5199\u4F1A\u88AB\u8986\u76D6\u3002`
4035
+ );
4036
+ for (const w of plan.subsequentWrites.slice(0, 10)) {
4037
+ console.log(
4038
+ ` - ${w.ts} ${w.method} outcome=${w.outcome} auditId=${w.auditId}` + (w.commit ? ` commit=${w.commit.slice(0, 60)}` : "")
4039
+ );
4040
+ }
4041
+ if (plan.subsequentWrites.length > 10) {
4042
+ console.log(` \u2026\u2026 \u8FD8\u6709 ${plan.subsequentWrites.length - 10} \u6761\uFF0C\u8BF7\u8FD0\u884C audit list --resource-key "${plan.resourceKey ?? ""}" --json \u67E5\u770B\u5B8C\u6574\u5386\u53F2`);
4043
+ }
4044
+ }
4045
+ if (plan.steps.length > 0) {
4046
+ console.log(`
4047
+ \u8865\u507F\u5199\u6B65\u9AA4\uFF08\u4EC5\u5C55\u793A\uFF0C\u672A\u4FEE\u6539\u7EBF\u4E0A\u8D44\u6E90\uFF09\uFF1A`);
4048
+ console.log(JSON.stringify(plan.steps, null, 2));
3896
4049
  console.log(
3897
- '\n\u786E\u8BA4\u65E0\u8BEF\u540E\u6267\u884C\uFF1Asiluzan-tso audit restore-apply --id <\u540C\u4E0A> --i-confirm [--commit "\u2026"]\n'
4050
+ `
4051
+ \u786E\u8BA4\u65E0\u8BEF\u540E\u6267\u884C\uFF1A
4052
+ siluzan-tso audit restore-apply --id ${plan.auditId} --i-confirm --ack-subsequent-writes ${plan.guardChecks.ackSubsequentWrites} [--commit "\u56DE\u9000\u8BF4\u660E"]
4053
+ `
3898
4054
  );
3899
4055
  } else {
3900
- console.log("\n\u65E0\u6CD5\u751F\u6210\u53EF\u6267\u884C\u7684\u8865\u507F\u5199\u8BA1\u5212\uFF1A");
3901
- console.log(JSON.stringify(envelope, null, 2));
4056
+ console.log(`
4057
+ \u5F53\u524D supportability=${plan.supportability.code}\uFF0C\u65E0\u53EF\u6267\u884C step\u3002
4058
+ `);
3902
4059
  }
3903
4060
  }
3904
4061
  async function runAuditRestoreApply(opts) {
@@ -3913,19 +4070,41 @@ async function runAuditRestoreApply(opts) {
3913
4070
  console.error("\u8BF7\u4F7F\u7528 --id <auditId>");
3914
4071
  process.exit(1);
3915
4072
  }
3916
- const record = await findWriteAuditRecordByAuditId(TSO_WRITE_AUDIT_NS3, auditId);
3917
- const snap = record?.preSnapshotRef ? await loadSnapshotForRecord(record) : null;
3918
- if (!record || record.outcome !== "success" || !snap) {
3919
- console.error("\n\u274C \u65E0\u6CD5\u6267\u884C\uFF1A\u8BB0\u5F55\u4E0D\u5B58\u5728\u3001\u975E\u6210\u529F\u5199\u6216\u5FEB\u7167\u4E0D\u53EF\u7528\u3002\u8BF7 audit restore-plan --id \u2026 \u67E5\u770B\u539F\u56E0\u3002\n");
4073
+ const ctx = await buildRestoreContext(auditId);
4074
+ if (ctx.supportability.code !== "ready_put_patch") {
4075
+ console.error(
4076
+ `
4077
+ \u274C \u65E0\u6CD5\u6267\u884C\uFF1Asupportability=${ctx.supportability.code}
4078
+ \u539F\u56E0\uFF1A${ctx.supportability.reason}` + (ctx.supportability.hint ? `
4079
+ \u5EFA\u8BAE\uFF1A${ctx.supportability.hint}` : "") + `
4080
+ \u8BF7\u5148 siluzan-tso audit restore-plan --id ${auditId} \u67E5\u770B\u5B8C\u6574\u8BCA\u65AD\u3002
4081
+ `
4082
+ );
3920
4083
  process.exit(1);
3921
4084
  }
3922
- const m = snap.originalMethod.toUpperCase();
3923
- if (m !== "PUT" && m !== "PATCH") {
3924
- console.error(`
3925
- \u274C \u5F53\u524D\u4EC5\u652F\u6301 PUT/PATCH \u7684\u8865\u507F\u5199\uFF0C\u672C\u6761\u4E3A ${m}\u3002
3926
- `);
4085
+ const expected = ctx.successfulSubsequentWriteCount;
4086
+ const ack = Number.isInteger(opts.ackSubsequentWrites) ? Number(opts.ackSubsequentWrites) : -1;
4087
+ if (ack < 0) {
4088
+ console.error(
4089
+ `
4090
+ \u274C \u62D2\u7EDD\u6267\u884C\uFF1A\u5FC5\u987B\u663E\u5F0F\u786E\u8BA4\u4F1A\u88AB\u8986\u76D6\u7684"\u540C\u8D44\u6E90\u540E\u7EED\u6210\u529F\u5199"\u6570\u91CF\u3002
4091
+ \u5F53\u524D\u626B\u63CF\u7ED3\u679C\uFF1A${expected} \u6761\uFF1B\u8BF7\u8FFD\u52A0\uFF1A--ack-subsequent-writes ${expected}
4092
+ \u82E5\u8BE5\u6570\u91CF\u4E0D\u4E3A 0\uFF0C\u610F\u5473\u7740\u6709\u66F4\u665A\u7684\u6210\u529F\u5199\u4F1A\u88AB\u672C\u6B21\u56DE\u9000\u8986\u76D6\uFF1B\u8BF7\u5148 restore-plan \u590D\u6838\u3002
4093
+ `
4094
+ );
3927
4095
  process.exit(1);
3928
4096
  }
4097
+ if (ack !== expected) {
4098
+ console.error(
4099
+ `
4100
+ \u274C \u62D2\u7EDD\u6267\u884C\uFF1A--ack-subsequent-writes=${ack} \u4E0E\u5F53\u524D\u626B\u63CF\u7ED3\u679C ${expected} \u4E0D\u4E00\u81F4\u3002
4101
+ \u8BF4\u660E plan \u4E0E apply \u4E4B\u95F4\u53C8\u53D1\u751F\u4E86\u65B0\u5199\uFF0C\u8BF7\u91CD\u65B0\u8FD0\u884C restore-plan \u590D\u6838\u540E\u518D apply\u3002
4102
+ `
4103
+ );
4104
+ process.exit(1);
4105
+ }
4106
+ const snap = ctx.snapshot;
4107
+ const method = snap.originalMethod.toUpperCase();
3929
4108
  if (getResolvedWriteAuditCommit().trim() === "") {
3930
4109
  setWriteAuditCliCommit(`compensate:restore:${auditId}`);
3931
4110
  }
@@ -3933,15 +4112,20 @@ async function runAuditRestoreApply(opts) {
3933
4112
  await apiFetch2(
3934
4113
  snap.originalUrl,
3935
4114
  config,
3936
- { method: m, body: JSON.stringify(snap.body) },
4115
+ { method, body: JSON.stringify(snap.body) },
3937
4116
  Boolean(opts.verbose)
3938
4117
  );
3939
- const out = { ok: true, auditId, message: "\u8865\u507F\u5199\u5DF2\u63D0\u4EA4\uFF08\u5E76\u8BB0\u5165\u65B0\u7684\u5BA1\u8BA1\u884C\uFF09\u3002" };
4118
+ const out = {
4119
+ ok: true,
4120
+ auditId,
4121
+ overwrittenSubsequentSuccessfulWrites: expected,
4122
+ message: "\u8865\u507F\u5199\u5DF2\u63D0\u4EA4\uFF08\u5E76\u8BB0\u5165\u65B0\u7684\u5BA1\u8BA1\u884C\uFF09\u3002"
4123
+ };
3940
4124
  if (opts.json) {
3941
4125
  console.log(JSON.stringify(out, null, 2));
3942
4126
  } else {
3943
4127
  console.log(`
3944
- ${out.message}
4128
+ ${out.message}\uFF08\u5DF2\u8986\u76D6 ${expected} \u6761\u540E\u7EED\u6210\u529F\u5199\uFF09
3945
4129
  `);
3946
4130
  }
3947
4131
  }
@@ -14103,7 +14287,11 @@ auditCmd.command("list").description("\u5217\u51FA\u6700\u8FD1 N \u4E2A\u5317\u4
14103
14287
  "--json-out <path>",
14104
14288
  "\u843D\u76D8\uFF08\u76EE\u5F55\u6216 *.json \u6587\u4EF6\u8DEF\u5F84\uFF09\u5E76\u66F4\u65B0 cli-manifest.json\uFF08\u4E0E --json \u4E92\u65A5\uFF09\uFF1Bstdout \u4E00\u884C\u6458\u8981 JSON\uFF0C\u542B outlineFile\uFF08TS \u5F0F\u7C7B\u578B\u5728 `*.outline.txt`\uFF09",
14105
14289
  void 0
14106
- ).option("--failures-only", "\u4EC5\u5931\u8D25\u8BB0\u5F55", false).option("--match <sub>", "\u5B50\u4E32\u5339\u914D commit/pathname/cmd/url/auditId/runId\uFF08\u626B JSONL\uFF09", void 0).option("--method <verb>", "HTTP \u65B9\u6CD5\u8FC7\u6EE4\uFF0C\u5982 PUT\u3001POST", void 0).option("--outcome <success|failure>", "\u6309\u7ED3\u679C\u8FC7\u6EE4", void 0).option("--run-id <id>", "\u6309 runId \u5B50\u4E32\u8FC7\u6EE4\uFF08\u73AF\u5883\u53D8\u91CF SILUZAN_AUDIT_RUN_ID \u5199\u5165\u7684\u8BB0\u5F55\uFF09", void 0).option("--since-ts <iso>", "\u4EC5 ts \u4E0D\u65E9\u4E8E\u8BE5 ISO \u65F6\u95F4\uFF08\u5728 --days \u7A97\u53E3\u5185\u518D\u7B5B\uFF09", void 0).action(
14290
+ ).option("--failures-only", "\u4EC5\u5931\u8D25\u8BB0\u5F55", false).option("--match <sub>", "\u5B50\u4E32\u5339\u914D commit/pathname/cmd/url/auditId/runId\uFF08\u626B JSONL\uFF09", void 0).option("--method <verb>", "HTTP \u65B9\u6CD5\u8FC7\u6EE4\uFF0C\u5982 PUT\u3001POST", void 0).option("--outcome <success|failure>", "\u6309\u7ED3\u679C\u8FC7\u6EE4", void 0).option("--run-id <id>", "\u6309 runId \u5B50\u4E32\u8FC7\u6EE4\uFF08\u73AF\u5883\u53D8\u91CF SILUZAN_AUDIT_RUN_ID \u5199\u5165\u7684\u8BB0\u5F55\uFF09", void 0).option("--since-ts <iso>", "\u4EC5 ts \u4E0D\u65E9\u4E8E\u8BE5 ISO \u65F6\u95F4\uFF08\u5728 --days \u7A97\u53E3\u5185\u518D\u7B5B\uFF09", void 0).option(
14291
+ "--resource-key <key>",
14292
+ "\u7CBE\u786E\u5339\u914D resourceKey\uFF08host+pathname\uFF09\uFF0C\u7528\u4E8E restore-plan \u914D\u5957\u8FFD\u6EAF\u540C\u8D44\u6E90\u5168\u91CF\u5386\u53F2",
14293
+ void 0
14294
+ ).action(
14107
14295
  async (opts) => {
14108
14296
  await runAuditList({
14109
14297
  days: opts.days,
@@ -14114,7 +14302,8 @@ auditCmd.command("list").description("\u5217\u51FA\u6700\u8FD1 N \u4E2A\u5317\u4
14114
14302
  method: opts.method,
14115
14303
  outcome: opts.outcome,
14116
14304
  runId: opts.runId,
14117
- sinceTs: opts.sinceTs
14305
+ sinceTs: opts.sinceTs,
14306
+ resourceKey: opts.resourceKey
14118
14307
  });
14119
14308
  }
14120
14309
  );
@@ -14128,11 +14317,18 @@ auditCmd.command("show").description("\u6309 auditId \u5C55\u793A\u5355\u6761\u5
14128
14317
  auditCmd.command("restore-plan").description("\u6839\u636E\u5BA1\u8BA1 + \u5199\u524D\u5FEB\u7167\u751F\u6210\u8865\u507F\u5199\u8BA1\u5212\uFF08\u53EA\u8BFB\uFF0C\u4E0D\u8C03\u7528\u4E1A\u52A1\u63A5\u53E3\uFF09").requiredOption("--id <auditId>", "\u76EE\u6807\u5BA1\u8BA1 auditId").option("--json", "\u8F93\u51FA JSON", false).option("--json-out <path>", "\u843D\u76D8\uFF08\u4E0E --json \u4E92\u65A5\uFF09", void 0).action(async (opts) => {
14129
14318
  await runAuditRestorePlan({ id: opts.id, json: Boolean(opts.json), jsonOut: opts.jsonOut });
14130
14319
  });
14131
- auditCmd.command("restore-apply").description("\u6267\u884C\u8865\u507F\u5199\uFF08\u5C06\u8D44\u6E90\u5199\u56DE\u5FEB\u7167\u4E2D\u7684\u72B6\u6001\uFF09\uFF1B\u987B\u5148 restore-plan \u5E76\u7ECF --i-confirm").requiredOption("--id <auditId>", "\u76EE\u6807\u5BA1\u8BA1 auditId").option("--i-confirm", "\u786E\u8BA4\u5DF2\u9605\u8BFB restore-plan \u5E76\u540C\u610F\u6267\u884C", false).option("-t, --token <token>", "JWT\uFF08\u53EF\u9009\uFF0C\u4E0E\u5168\u5C40\u4E00\u81F4\uFF09", void 0).option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF", false).option("--json", "stdout \u4EC5\u8F93\u51FA JSON", false).action(
14320
+ auditCmd.command("restore-apply").description(
14321
+ "\u6267\u884C\u8865\u507F\u5199\uFF08\u5C06\u8D44\u6E90\u5199\u56DE\u5FEB\u7167\u4E2D\u7684\u72B6\u6001\uFF09\uFF1B\u987B\u5148 restore-plan\uFF0C\u5E76\u7ECF --i-confirm + --ack-subsequent-writes \u5B88\u536B"
14322
+ ).requiredOption("--id <auditId>", "\u76EE\u6807\u5BA1\u8BA1 auditId").option("--i-confirm", "\u786E\u8BA4\u5DF2\u9605\u8BFB restore-plan \u5E76\u540C\u610F\u6267\u884C", false).option(
14323
+ "--ack-subsequent-writes <n>",
14324
+ "\u663E\u5F0F\u786E\u8BA4\u4F1A\u88AB\u672C\u6B21\u56DE\u9000\u8986\u76D6\u7684\u540C resourceKey \u540E\u7EED\u6210\u529F\u5199\u6570\u91CF\uFF1B\u5FC5\u987B\u7B49\u4E8E restore-plan.guardChecks.ackSubsequentWrites",
14325
+ (v) => Number.parseInt(v, 10)
14326
+ ).option("-t, --token <token>", "JWT\uFF08\u53EF\u9009\uFF0C\u4E0E\u5168\u5C40\u4E00\u81F4\uFF09", void 0).option("--verbose", "\u8BE6\u7EC6\u9519\u8BEF", false).option("--json", "stdout \u4EC5\u8F93\u51FA JSON", false).action(
14132
14327
  async (opts) => {
14133
14328
  await runAuditRestoreApply({
14134
14329
  id: opts.id,
14135
14330
  iConfirm: Boolean(opts.iConfirm),
14331
+ ackSubsequentWrites: opts.ackSubsequentWrites,
14136
14332
  token: opts.token,
14137
14333
  verbose: Boolean(opts.verbose),
14138
14334
  json: Boolean(opts.json)
@@ -67,6 +67,8 @@ allowed-tools: Bash(siluzan-tso:*) Read Write
67
67
 
68
68
  ---
69
69
 
70
+ `--commit` 所有的写/修改命令都会有commit字段,请你填写修改前,修改后的值。方便后期出问题时排查或恢复
71
+
70
72
  ## 职责划分
71
73
 
72
74
  **定位**:`siluzan-tso` + 本 Skill 提供 **可组合的读能力(检查项)** 与 **可验证的写能力(最终操作)**;**不负责**在进程内长期驻留、定时轮询、复合条件规则引擎、或替代宿主的通知渠道。
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "slug": "siluzan-tso",
3
- "version": "1.1.15-beta.1",
4
- "publishedAt": 1777539704629
3
+ "version": "1.1.15-beta.2",
4
+ "publishedAt": 1778032713167
5
5
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  - **补偿写**:再次调用写接口,把资源字段写回「写前快照」中的状态;**不是**数据库或平台的事务回滚 API。
6
6
  - **真值在磁盘**:检索与定位一律通过 `siluzan-tso audit list` / `audit show` 读 `~/.siluzan/write-audit/tso/*.jsonl`,**不要**依赖对话中的历史列表。
7
+ - **resourceKey**:写审计在变异请求时由 `host+pathname` 派生,**同一资源的多次写共享同一 key**——`restore-plan` 据此扫描"目标 audit 之后该资源还有哪些写",让你看见回退会顺带覆盖什么。
7
8
 
8
9
  ## 写操作必填 commit
9
10
 
@@ -11,10 +12,9 @@
11
12
 
12
13
  - 命令行:`--commit "说明"`(可出现在任意位置,与 `grep` 解析一致)
13
14
 
14
-
15
15
  二者同时存在时 **命令行优先**。缺省则 CLI **stderr 报错并退出**,不发起 HTTP。
16
16
 
17
- `audit list`、`audit show`、`audit restore-plan` 不产生业务写,不要求 commit。`audit restore-apply` 须加 `--i-confirm`;若仍未传 commit,会自动使用 `compensate:restore:<auditId>` 作为本条补偿写的说明。
17
+ `audit list`、`audit show`、`audit restore-plan` 不产生业务写,不要求 commit。`audit restore-apply` 须加 `--i-confirm` 与 `--ack-subsequent-writes`;若仍未传 commit,会自动使用 `compensate:restore:<auditId>` 作为本条补偿写的说明。
18
18
 
19
19
  ## 可选 runId
20
20
 
@@ -22,17 +22,80 @@
22
22
 
23
23
  ## 写前快照(白名单)
24
24
 
25
- 仅对部分 **Google 网关 `SemManagement` PUT/PATCH** 在写前自动 `GET` 同 URL(去 query),快照存于 `write-audit/tso/snapshots/<auditId>.json`,审计行含 `preSnapshotRef`。超限或 GET 失败时记 `preSnapshotSkipped`,不阻断主请求。
26
-
27
- ## 补偿写流程(Agent / 人类)
28
-
29
- 1. `siluzan-tso audit list --days 14 --match "关键词" --json`(或 `--match` 用 auditId 片段)
30
- 2. `siluzan-tso audit show --id <auditId> --json` 确认 `preSnapshotFileReadable`
31
- 3. `siluzan-tso audit restore-plan --id <auditId> --json`,检查 `plan.status === "ready"`
32
- 4. 人类确认后:`siluzan-tso audit restore-apply --id <auditId> --i-confirm [--commit "人工备注"]`
33
-
34
- **禁止**跳过 `restore-plan` 直接 `restore-apply`。**禁止**根据 `commit` 文本编造请求体;**必须以** `restore-plan` 输出的 `steps[].body` 为准。
25
+ 仅当 PUT/PATCH 命中以下 **Google 广告网关** 路径时,CLI 会在写前自动 `GET` 同 URL(去 query),快照存于 `write-audit/tso/snapshots/<auditId>.json`,审计行含 `preSnapshotRef`:
26
+
27
+ 1. `/SemManagement/...` —— 历史在用的优化/系列侧接口
28
+ 2. `/campaignmanagement/campaign/{accountId}/{campaignId}` —— 广告系列单资源详情(`ad campaign-status / campaign-delete / campaign-edit` 走这里)
29
+
30
+ 未命中时审计行只有 `preSnapshotSkipped`、不会阻断主请求;`restore-plan` 也会返回 `supportability.code = "no_snapshot"` 并给出"人工反向写"的 hint。
31
+
32
+ 明确**未纳入**白名单的资源(业务代码绕开了"单资源 GET",要么端点不可靠、要么 GET shape PUT body 不对齐——盲目 apply 会写错形状):
33
+
34
+ - `/adgroupnmanagement/adgroup/{acc}/{id}`(业务走 list + filter)
35
+ - `/admanagement/campaign/{acc}/{adId}`(业务走 list + filter)
36
+ - `/keywordmanagement/Keyword/{acc}/batch`(批量 PUT,body 是数组)
37
+ - `/negativekeywordmanagement/negativekeyword/{acc}/{id}`(业务走 list + filter)
38
+ - `/campaignmanagement/` 下的 `v2/list/...`、`v2/(targeted|excluded)locations/...`、`criterion/...`、`geolocations/...`
39
+
40
+ POST 创建型与 DELETE 删除型 **整体**不在快照白名单内,`restore-plan` 会给出 `unsupported_method_post` / `unsupported_method_delete` 的明确诊断与下一步建议。
41
+
42
+ ## restore-plan 输出结构(Agent 决策依据)
43
+
44
+ ```jsonc
45
+ {
46
+ "auditDir": "...",
47
+ "plan": {
48
+ "auditId": "...",
49
+ "resourceKey": "googleapi.mysiluzan.com/SemManagement/...",
50
+ "supportability": {
51
+ "code": "ready_put_patch | audit_not_found | v1_record_no_audit_id | not_success_write | no_snapshot | snapshot_missing_or_invalid | unsupported_method_post | unsupported_method_delete | unsupported_method_other",
52
+ "reason": "一句话原因",
53
+ "hint": "仅 unsupported 时给出的人/Agent 下一步建议"
54
+ },
55
+ "willMutate": true,
56
+ "target": { "ts": "...", "method": "PUT", "pathname": "...", "outcome": "success", "commit": "...", "invokedCommand": "...", "httpStatus": 200 },
57
+ "snapshot": { "capturedAt": "...", "originalMethod": "PUT", "originalUrl": "...", "bodyUtf8Length": 1234, "bodySha256Prefix": "..." },
58
+ "subsequentWrites": [
59
+ { "auditId": "...", "ts": "...", "method": "PUT", "outcome": "success", "pathname": "...", "commit": "...", "hasSnapshot": true }
60
+ ],
61
+ "guardChecks": { "ackSubsequentWrites": 0 },
62
+ "steps": [{ "order": 1, "method": "PUT", "url": "...", "body": { /* snapshot.body */ }, "bodyUtf8Length": 1234, "bodySha256Prefix": "..." }]
63
+ }
64
+ }
65
+ ```
66
+
67
+ 要点:
68
+
69
+ - `supportability.code` 是分流路标,Agent 必须据此判断是否进入 apply 分支。
70
+ - `subsequentWrites` 列出 **同 resourceKey、ts > target.ts** 的全部写(成功+失败)。`guardChecks.ackSubsequentWrites` 则是其中**成功**的数量——`restore-apply` 会要求显式确认。
71
+ - `steps` 仅在 `supportability.code === "ready_put_patch"` 时非空,单步 PUT/PATCH 重放 `snapshot.body`。
72
+
73
+ ## restore-apply 守卫
74
+
75
+ ```
76
+ siluzan-tso audit restore-apply \
77
+ --id <auditId> \
78
+ --i-confirm \
79
+ --ack-subsequent-writes <plan.guardChecks.ackSubsequentWrites>
80
+ ```
81
+
82
+ - `--i-confirm`:人/Agent 已读 plan,承担覆盖风险。
83
+ - `--ack-subsequent-writes <N>`:必须等于 plan 显示的成功后续写数量。**apply 时会再 build 一次上下文**:如果 N 与最新扫描结果不一致(说明 plan→apply 期间又发生了新写),CLI 会拒绝执行——这是防 TOCTOU 的关键守卫。
84
+
85
+ ## 自主回退工作流(Agent / 人类)
86
+
87
+ 1. **定位 audit**:`siluzan-tso audit list --days 14 --match "关键词" --json`。
88
+ 2. **看完整审计行**:`siluzan-tso audit show --id <auditId> --json`,确认 `preSnapshotFileReadable === true`。
89
+ 3. **生成回退计划**:`siluzan-tso audit restore-plan --id <auditId> --json`。
90
+ - 检查 `plan.supportability.code === "ready_put_patch"`。
91
+ - **必读** `plan.subsequentWrites`:若存在成功的后续写,意味着回退会覆盖那些更晚的修改;按需可先用 `siluzan-tso audit list --resource-key "<plan.resourceKey>" --json` 把同资源全量历史拉出来交叉确认。
92
+ - 决定是接受这个覆盖,还是放弃回退/换另一种修复路径。
93
+ 4. **执行**:`siluzan-tso audit restore-apply --id <auditId> --i-confirm --ack-subsequent-writes <N>`,N 来自 plan.guardChecks.ackSubsequentWrites。
94
+
95
+ **禁止**跳过 `restore-plan` 直接 `restore-apply`。
96
+ **禁止**根据 `commit` 文本编造请求体;**必须以** `restore-plan` 输出的 `steps[].body` 为准。
97
+ **禁止**在 `subsequentWrites` 非空且未与用户确认的情况下,盲目用 plan 中的数字 ack 后强行 apply。
35
98
 
36
99
  ## v1 历史审计
37
100
 
38
- 旧版 JSONL 无 `auditId`/`commit`/`preSnapshotRef`,无法使用 `restore-plan` / `restore-apply`。
101
+ 旧版 JSONL 无 `auditId`/`commit`/`preSnapshotRef`,`restore-plan` 会返回 `supportability.code === "v1_record_no_audit_id"` / `audit_not_found`,无法 `restore-apply`,仅可作 `audit show` 参考。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siluzan-tso-cli",
3
- "version": "1.1.15-beta.1",
3
+ "version": "1.1.15-beta.2",
4
4
  "description": "Siluzan 广告账户管理 CLI — 查询账户、余额、消耗数据,管理绑定关系与充值。",
5
5
  "keywords": [
6
6
  "ad-account",