Skip to content

Commit 1467369

Browse files
author
SqlRush
committed
Support microcompact ISO TTL durations
1 parent e34578f commit 1467369

5 files changed

Lines changed: 137 additions & 0 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ M6 补充:microcompact disk cache loader 现在会把 `value` 字段中的 tex
5454

5555
M6 补充:microcompact disk cache loader 的 relative TTL 现在接受分钟、小时和天级字段别名,包括 `ttlMinutes``expiresInHours``validForDays` 及 snake/camel 相邻形式,恢复 cached microcompact 过期时间时不再只依赖秒/毫秒字段。
5656

57+
M6 补充:microcompact disk cache loader 的 relative TTL 字符串现在接受固定单位 ISO-8601 duration,例如 `PT1H30M``P1D``P1DT2H`,同时仍拒绝年/月这类长度不固定的 duration,避免 cache expiry 产生歧义。
58+
5759
M6 补充:sidechain/subagent lifecycle state loader 现在接受 `subagent_started``agentStarted``task_failed``sidechainCompleted` 等相邻 subtype aliases,支持 `taskID`/`workerId`/`runId``agentName`/`kind``resultText`/`finalMessage` 等字段,并在 failed/cancelled subtype 没有显式 status 时自动归一状态。
5860

5961
M6 补充:sidechain/subagent lifecycle content 读取现在接受 JSON:API/resource-style `resource`/`attributes`/`properties` wrapper,外层 resource `id` 可作为 sidechain ID fallback,内层 agent metadata、status/outcome 和 summary 字段仍能恢复到 state/list/resume。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ test/parity/ # golden tests against TS/official behavior
293293
- 本轮补充:microcompact disk cache loader 现在接受 `cacheEntry`/`cache_entry``micro_compact`/`micro_compact_result` wrapper,以及 `summaryMarkdown`/`resultSummary`/`compressedText``cacheDigest`/`digestHash`/`fingerprint``summarizedCount`/`retainedCount``formatVersion``ttlMilliseconds`/`expiresInMilliseconds`/`maxAgeMilliseconds` 等相邻实现字段别名。
294294
- 本轮补充:microcompact disk cache loader 继续接受相邻 timestamp/expiry aliases,包括 `cachedAt``cacheCreatedAt``storedAt``generatedAt``updatedAt``timestamp``expiry``expirationTime``validUntil``notAfter`,以及 `timeToLiveSeconds``validForMs` 等相对 TTL 字段。
295295
- 本轮补充:microcompact disk cache loader 的相对 TTL 现在也接受分钟、小时和天级别名,例如 `ttlMinutes``expiresInHours``validForDays` 及 snake/camel 相邻形式,恢复 cached microcompact 过期时间时不再局限于秒/毫秒。
296+
- 本轮补充:microcompact disk cache loader 的相对 TTL 字符串现在也接受固定单位 ISO-8601 duration,例如 `PT1H30M``P1D``P1DT2H`,并继续拒绝年/月这类不定长 duration。
296297
- 本轮补充:microcompact disk cache loader 和 prune 现在接受 digest 缺失但文件名已 keyed 的 cache entry,会用 `<digest>.json` 文件名作为 digest fallback,同时仍保留显式 digest mismatch 的 invalid-cache guard。
297298
- 本轮补充:microcompact disk cache loader 的 `cached`/`fromCache`/`cacheHit`/`isCached` 布尔字段现在接受 JSON bool、`true`/`false``yes`/`no``on`/`off``1`/`0` 数字/字符串形态。
298299
- 本轮补充:microcompact disk cache loader 现在会从 `metadata`/`meta`/`cacheInfo`/`cacheDetails`/`cacheEntry`/`entry`/`record`/`cache` 等 sidecar object 中补缺失的 digest、version、cache-hit、timestamp、TTL 和 count aliases;主 summary payload 字段仍保持优先。

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ M6 progress now includes:
127127
- `internal/compact`: microcompact disk cache loading also accepts adjacent cache-entry wrappers and aliases such as `cacheEntry`/`cache_entry`, `micro_compact`/`micro_compact_result`, `summaryMarkdown`/`resultSummary`/`compressedText`, `cacheDigest`/`digestHash`/`fingerprint`, `summarizedCount`/`retainedCount`, `formatVersion`, and millisecond TTL aliases such as `ttlMilliseconds`, `expiresInMilliseconds`, and `maxAgeMilliseconds`.
128128
- `internal/compact`: microcompact disk cache loading now also accepts adjacent timestamp aliases such as `cachedAt`, `cacheCreatedAt`, `storedAt`, `generatedAt`, `updatedAt`, `timestamp`, `expiry`, `expirationTime`, `validUntil`, `notAfter`, and relative TTL aliases such as `timeToLiveSeconds` and `validForMs`.
129129
- `internal/compact`: microcompact disk cache relative TTL loading now also accepts minute, hour, and day aliases such as `ttlMinutes`, `expiresInHours`, and `validForDays`, including adjacent snake/camel forms.
130+
- `internal/compact`: microcompact disk cache relative TTL strings now also accept fixed-unit ISO-8601 durations such as `PT1H30M`, `P1D`, and `P1DT2H`, while rejecting ambiguous year/month durations.
130131
- `internal/compact`: microcompact disk cache loading now fills missing cache metadata from sidecar objects such as `metadata`, `meta`, `cacheInfo`, `cacheDetails`, `cacheEntry`, `entry`, `record`, and `cache`, preserving primary result fields while recovering digest/version/cache-hit/timestamp/TTL/count aliases stored outside the summary payload.
131132
- `internal/compact`: microcompact disk cache loading and pruning now use the `<digest>.json` filename as a digest fallback when a cache entry omits an internal digest, while preserving explicit digest mismatch pruning/errors.
132133
- `internal/compact`: microcompact disk cache loading now accepts non-strict cache-hit booleans for `cached`/`fromCache`/`cacheHit`/`isCached`, including `yes`/`no`, `on`/`off`, and `1`/`0` JSON number or string values.

internal/compact/compact_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,71 @@ func TestLoadMicroResultAcceptsMinuteHourDayTTLAliases(t *testing.T) {
423423
}
424424
}
425425

426+
func TestLoadMicroResultAcceptsISO8601TTLDurations(t *testing.T) {
427+
cacheDir := filepath.Join(t.TempDir(), "micro")
428+
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
429+
t.Fatal(err)
430+
}
431+
for _, tc := range []struct {
432+
digest string
433+
field string
434+
want time.Duration
435+
}{
436+
{digest: "ttl-iso-hours-minutes", field: `"ttl":"PT1H30M"`, want: 90 * time.Minute},
437+
{digest: "expires-iso-day-hours", field: `"expiresIn":"P1DT2H"`, want: 26 * time.Hour},
438+
{digest: "valid-iso-minutes", field: `"validFor":"PT45M"`, want: 45 * time.Minute},
439+
} {
440+
payload := fmt.Sprintf(`{
441+
"summary": %q,
442+
"digest": %q,
443+
"version": "microcompact.v1",
444+
"createdAt": 100,
445+
%s
446+
}`, tc.digest+" summary", tc.digest, tc.field)
447+
if err := os.WriteFile(microResultPath(cacheDir, tc.digest), []byte(payload), 0o600); err != nil {
448+
t.Fatal(err)
449+
}
450+
result, ok, err := LoadMicroResult(cacheDir, tc.digest)
451+
if err != nil {
452+
t.Fatalf("%s load error: %v", tc.digest, err)
453+
}
454+
if !ok {
455+
t.Fatalf("%s was not loaded", tc.digest)
456+
}
457+
if want := time.Unix(100, 0).UTC().Add(tc.want); !result.ExpiresAt.Equal(want) {
458+
t.Fatalf("%s expires_at = %s, want %s", tc.digest, result.ExpiresAt, want)
459+
}
460+
}
461+
}
462+
463+
func TestLoadMicroResultRejectsAmbiguousISO8601TTLDurations(t *testing.T) {
464+
cacheDir := filepath.Join(t.TempDir(), "micro")
465+
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
466+
t.Fatal(err)
467+
}
468+
for _, tc := range []struct {
469+
digest string
470+
field string
471+
}{
472+
{digest: "ttl-iso-month", field: `"ttl":"P1M"`},
473+
{digest: "ttl-iso-year", field: `"ttl":"P1Y"`},
474+
} {
475+
payload := fmt.Sprintf(`{
476+
"summary": %q,
477+
"digest": %q,
478+
"version": "microcompact.v1",
479+
"createdAt": 100,
480+
%s
481+
}`, tc.digest+" summary", tc.digest, tc.field)
482+
if err := os.WriteFile(microResultPath(cacheDir, tc.digest), []byte(payload), 0o600); err != nil {
483+
t.Fatal(err)
484+
}
485+
if _, _, err := LoadMicroResult(cacheDir, tc.digest); err == nil {
486+
t.Fatalf("%s loaded ambiguous ISO-8601 TTL", tc.digest)
487+
}
488+
}
489+
}
490+
426491
func TestLoadMicroResultAcceptsWrappedCacheObjects(t *testing.T) {
427492
cacheDir := filepath.Join(t.TempDir(), "micro")
428493
if err := os.MkdirAll(cacheDir, 0o755); err != nil {

internal/compact/micro.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,9 @@ func microParseJSONDuration(raw json.RawMessage, field string) (time.Duration, e
884884
if duration, err := time.ParseDuration(text); err == nil {
885885
return duration, nil
886886
}
887+
if duration, ok := microParseISO8601Duration(text); ok {
888+
return duration, nil
889+
}
887890
number, err := strconv.ParseFloat(text, 64)
888891
if err != nil {
889892
return 0, err
@@ -903,6 +906,71 @@ func microParseJSONDuration(raw json.RawMessage, field string) (time.Duration, e
903906
return 0, fmt.Errorf("invalid duration field %q", field)
904907
}
905908

909+
func microParseISO8601Duration(text string) (time.Duration, bool) {
910+
value := strings.ToUpper(strings.TrimSpace(text))
911+
if len(value) < 2 || value[0] != 'P' {
912+
return 0, false
913+
}
914+
value = value[1:]
915+
inTime := false
916+
total := 0.0
917+
for value != "" {
918+
if value[0] == 'T' {
919+
if inTime {
920+
return 0, false
921+
}
922+
inTime = true
923+
value = value[1:]
924+
continue
925+
}
926+
index := strings.IndexFunc(value, func(r rune) bool {
927+
return !((r >= '0' && r <= '9') || r == '.')
928+
})
929+
if index <= 0 {
930+
return 0, false
931+
}
932+
number, err := strconv.ParseFloat(value[:index], 64)
933+
if err != nil {
934+
return 0, false
935+
}
936+
unit := value[index]
937+
switch unit {
938+
case 'W':
939+
if inTime {
940+
return 0, false
941+
}
942+
total += number * float64(7*24*time.Hour)
943+
case 'D':
944+
if inTime {
945+
return 0, false
946+
}
947+
total += number * float64(24*time.Hour)
948+
case 'H':
949+
if !inTime {
950+
return 0, false
951+
}
952+
total += number * float64(time.Hour)
953+
case 'M':
954+
if !inTime {
955+
return 0, false
956+
}
957+
total += number * float64(time.Minute)
958+
case 'S':
959+
if !inTime {
960+
return 0, false
961+
}
962+
total += number * float64(time.Second)
963+
default:
964+
return 0, false
965+
}
966+
value = value[index+1:]
967+
}
968+
if total <= 0 {
969+
return 0, false
970+
}
971+
return time.Duration(total), true
972+
}
973+
906974
func microDurationFromFloat(value float64, field string) time.Duration {
907975
if microDurationFieldIsMillis(field) {
908976
return time.Duration(value * float64(time.Millisecond))

0 commit comments

Comments
 (0)