@leo000001/opencode-quota-sidebar 1.0.2 → 1.0.3
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 +17 -4
- package/dist/descendants.d.ts +22 -0
- package/dist/descendants.js +78 -0
- package/dist/events.d.ts +8 -0
- package/dist/events.js +31 -0
- package/dist/format.js +127 -23
- package/dist/index.js +190 -631
- package/dist/persistence.d.ts +13 -0
- package/dist/persistence.js +63 -0
- package/dist/providers/core/openai.js +2 -1
- package/dist/quota.js +18 -8
- package/dist/quota_service.d.ts +23 -0
- package/dist/quota_service.js +188 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +62 -24
- package/dist/storage_chunks.js +74 -1
- package/dist/storage_parse.js +8 -0
- package/dist/storage_paths.d.ts +1 -0
- package/dist/storage_paths.js +12 -4
- package/dist/title.d.ts +5 -0
- package/dist/title.js +26 -2
- package/dist/title_apply.d.ts +33 -0
- package/dist/title_apply.js +189 -0
- package/dist/title_refresh.d.ts +9 -0
- package/dist/title_refresh.js +46 -0
- package/dist/tools.d.ts +56 -0
- package/dist/tools.js +63 -0
- package/dist/types.d.ts +12 -0
- package/dist/usage.js +148 -47
- package/dist/usage_service.d.ts +31 -0
- package/dist/usage_service.js +417 -0
- package/package.json +1 -1
- package/quota-sidebar.config.example.json +5 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function createPersistenceScheduler<TState>(deps: {
|
|
2
|
+
statePath: string;
|
|
3
|
+
state: TState;
|
|
4
|
+
saveState: (statePath: string, state: TState, options: {
|
|
5
|
+
dirtyDateKeys: string[];
|
|
6
|
+
}) => Promise<void>;
|
|
7
|
+
}): {
|
|
8
|
+
markDirty: (dateKey: string | undefined) => void;
|
|
9
|
+
scheduleSave: () => void;
|
|
10
|
+
flushSave: () => Promise<void>;
|
|
11
|
+
persist: () => Promise<void>;
|
|
12
|
+
getDirtyCount: () => number;
|
|
13
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { debug, swallow } from './helpers.js';
|
|
2
|
+
export function createPersistenceScheduler(deps) {
|
|
3
|
+
const dirtyDateKeys = new Set();
|
|
4
|
+
let stateDirty = false;
|
|
5
|
+
let saveTimer;
|
|
6
|
+
let saveInFlight = Promise.resolve();
|
|
7
|
+
/**
|
|
8
|
+
* Capture and delete specific dirty keys instead of clearing the whole set.
|
|
9
|
+
* Keys added between capture and write completion are preserved.
|
|
10
|
+
*/
|
|
11
|
+
const persist = () => {
|
|
12
|
+
const dirty = Array.from(dirtyDateKeys);
|
|
13
|
+
if (dirty.length === 0 && !stateDirty)
|
|
14
|
+
return saveInFlight;
|
|
15
|
+
for (const key of dirty)
|
|
16
|
+
dirtyDateKeys.delete(key);
|
|
17
|
+
stateDirty = false;
|
|
18
|
+
const write = saveInFlight
|
|
19
|
+
.catch(swallow('persistState:wait'))
|
|
20
|
+
.then(() => deps.saveState(deps.statePath, deps.state, { dirtyDateKeys: dirty }))
|
|
21
|
+
.catch((error) => {
|
|
22
|
+
for (const key of dirty)
|
|
23
|
+
dirtyDateKeys.add(key);
|
|
24
|
+
stateDirty = true;
|
|
25
|
+
debug(`persistState failed: ${String(error)}`);
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
saveInFlight = write;
|
|
29
|
+
return write;
|
|
30
|
+
};
|
|
31
|
+
const scheduleSave = () => {
|
|
32
|
+
stateDirty = true;
|
|
33
|
+
if (saveTimer)
|
|
34
|
+
clearTimeout(saveTimer);
|
|
35
|
+
saveTimer = setTimeout(() => {
|
|
36
|
+
saveTimer = undefined;
|
|
37
|
+
void persist().catch(swallow('persistState:save'));
|
|
38
|
+
}, 200);
|
|
39
|
+
};
|
|
40
|
+
const flushSave = async () => {
|
|
41
|
+
if (saveTimer) {
|
|
42
|
+
clearTimeout(saveTimer);
|
|
43
|
+
saveTimer = undefined;
|
|
44
|
+
}
|
|
45
|
+
if (dirtyDateKeys.size > 0 || stateDirty) {
|
|
46
|
+
await persist();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await saveInFlight;
|
|
50
|
+
};
|
|
51
|
+
const markDirty = (dateKey) => {
|
|
52
|
+
if (!dateKey)
|
|
53
|
+
return;
|
|
54
|
+
dirtyDateKeys.add(dateKey);
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
markDirty,
|
|
58
|
+
scheduleSave,
|
|
59
|
+
flushSave,
|
|
60
|
+
persist,
|
|
61
|
+
getDirtyCount: () => dirtyDateKeys.size,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -66,12 +66,13 @@ async function fetchOpenAIQuota(ctx) {
|
|
|
66
66
|
1000;
|
|
67
67
|
if (ctx.updateAuth && ctx.auth.refresh && ctx.auth.expires) {
|
|
68
68
|
try {
|
|
69
|
-
await ctx.updateAuth(
|
|
69
|
+
await ctx.updateAuth(ctx.providerID, {
|
|
70
70
|
type: 'oauth',
|
|
71
71
|
access: ctx.auth.access,
|
|
72
72
|
refresh: ctx.auth.refresh,
|
|
73
73
|
expires: ctx.auth.expires,
|
|
74
74
|
accountId: ctx.auth.accountId,
|
|
75
|
+
enterpriseUrl: ctx.auth.enterpriseUrl,
|
|
75
76
|
});
|
|
76
77
|
debug('openai oauth token refreshed and persisted');
|
|
77
78
|
}
|
package/dist/quota.js
CHANGED
|
@@ -47,7 +47,19 @@ export function createQuotaRuntime() {
|
|
|
47
47
|
const adapter = resolveQuotaAdapter(providerID, providerOptions);
|
|
48
48
|
const normalizedProviderID = normalizeProviderID(providerID);
|
|
49
49
|
const baseURL = sanitizeBaseURL(providerOptions?.baseURL);
|
|
50
|
-
|
|
50
|
+
let keyBase = adapter?.id || normalizedProviderID;
|
|
51
|
+
// Some adapters normalize multiple provider IDs into one canonical ID.
|
|
52
|
+
// Preserve the original providerID in the cache key to avoid collisions
|
|
53
|
+
// when different auth entries are used (e.g. Copilot enterprise variants).
|
|
54
|
+
if (adapter?.id && normalizedProviderID !== providerID) {
|
|
55
|
+
keyBase = `${adapter.id}:${providerID}`;
|
|
56
|
+
}
|
|
57
|
+
// RightCode variants intentionally keep provider-specific labels (RC-openai,
|
|
58
|
+
// RC-foo). Preserve that identity in cache keys so snapshots don't collide.
|
|
59
|
+
if (adapter?.id === 'rightcode' &&
|
|
60
|
+
normalizedProviderID.startsWith('rightcode-')) {
|
|
61
|
+
keyBase = normalizedProviderID;
|
|
62
|
+
}
|
|
51
63
|
return baseURL ? `${keyBase}@${baseURL}` : keyBase;
|
|
52
64
|
};
|
|
53
65
|
const fetchQuotaSnapshot = async (providerID, authMap, config, updateAuth, providerOptions) => {
|
|
@@ -82,17 +94,15 @@ export function createQuotaRuntime() {
|
|
|
82
94
|
fetchQuotaSnapshot,
|
|
83
95
|
};
|
|
84
96
|
}
|
|
85
|
-
|
|
86
|
-
return fn(createQuotaRuntime());
|
|
87
|
-
}
|
|
97
|
+
const defaultRuntime = createQuotaRuntime();
|
|
88
98
|
export function normalizeProviderID(providerID) {
|
|
89
|
-
return
|
|
99
|
+
return defaultRuntime.normalizeProviderID(providerID);
|
|
90
100
|
}
|
|
91
101
|
export function resolveQuotaAdapter(providerID, providerOptions) {
|
|
92
|
-
return
|
|
102
|
+
return defaultRuntime.resolveQuotaAdapter(providerID, providerOptions);
|
|
93
103
|
}
|
|
94
104
|
export function quotaCacheKey(providerID, providerOptions) {
|
|
95
|
-
return
|
|
105
|
+
return defaultRuntime.quotaCacheKey(providerID, providerOptions);
|
|
96
106
|
}
|
|
97
107
|
export async function loadAuthMap(authPath) {
|
|
98
108
|
const parsed = await fs
|
|
@@ -112,5 +122,5 @@ export async function loadAuthMap(authPath) {
|
|
|
112
122
|
}, {});
|
|
113
123
|
}
|
|
114
124
|
export async function fetchQuotaSnapshot(providerID, authMap, config, updateAuth, providerOptions) {
|
|
115
|
-
return
|
|
125
|
+
return defaultRuntime.fetchQuotaSnapshot(providerID, authMap, config, updateAuth, providerOptions);
|
|
116
126
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
import type { QuotaSidebarConfig, QuotaSidebarState, QuotaSnapshot } from './types.js';
|
|
3
|
+
import type { AuthValue } from './providers/types.js';
|
|
4
|
+
export declare function createQuotaService(deps: {
|
|
5
|
+
quotaRuntime: {
|
|
6
|
+
normalizeProviderID: (providerID: string) => string;
|
|
7
|
+
resolveQuotaAdapter: (providerID: string, providerOptions?: Record<string, unknown>) => {
|
|
8
|
+
id: string;
|
|
9
|
+
} | undefined;
|
|
10
|
+
quotaCacheKey: (providerID: string, providerOptions?: Record<string, unknown>) => string;
|
|
11
|
+
fetchQuotaSnapshot: (providerID: string, authMap: Record<string, AuthValue>, config: QuotaSidebarConfig, updateAuth?: (providerID: string, next: unknown) => Promise<void>, providerOptions?: Record<string, unknown>) => Promise<QuotaSnapshot | undefined>;
|
|
12
|
+
};
|
|
13
|
+
config: QuotaSidebarConfig;
|
|
14
|
+
state: QuotaSidebarState;
|
|
15
|
+
authPath: string;
|
|
16
|
+
client: PluginInput['client'];
|
|
17
|
+
directory: string;
|
|
18
|
+
scheduleSave: () => void;
|
|
19
|
+
}): {
|
|
20
|
+
getQuotaSnapshots: (providerIDs: string[], options?: {
|
|
21
|
+
allowDefault?: boolean;
|
|
22
|
+
}) => Promise<QuotaSnapshot[]>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { TtlValueCache } from './cache.js';
|
|
2
|
+
import { swallow } from './helpers.js';
|
|
3
|
+
import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
|
|
4
|
+
export function createQuotaService(deps) {
|
|
5
|
+
const authCache = new TtlValueCache();
|
|
6
|
+
const providerOptionsCache = new TtlValueCache();
|
|
7
|
+
const inFlight = new Map();
|
|
8
|
+
const getAuthMap = async () => {
|
|
9
|
+
const cached = authCache.get();
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
const value = await loadAuthMap(deps.authPath);
|
|
13
|
+
return authCache.set(value, 30_000);
|
|
14
|
+
};
|
|
15
|
+
const getProviderOptionsMap = async () => {
|
|
16
|
+
const cached = providerOptionsCache.get();
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
const configClient = deps.client;
|
|
20
|
+
if (!configClient.config?.providers) {
|
|
21
|
+
return providerOptionsCache.set({}, 30_000);
|
|
22
|
+
}
|
|
23
|
+
const response = await configClient.config
|
|
24
|
+
.providers({
|
|
25
|
+
query: { directory: deps.directory },
|
|
26
|
+
throwOnError: true,
|
|
27
|
+
})
|
|
28
|
+
.catch(swallow('getProviderOptionsMap'));
|
|
29
|
+
const data = response &&
|
|
30
|
+
typeof response === 'object' &&
|
|
31
|
+
'data' in response &&
|
|
32
|
+
response.data &&
|
|
33
|
+
typeof response.data === 'object' &&
|
|
34
|
+
'providers' in response.data
|
|
35
|
+
? response.data.providers
|
|
36
|
+
: undefined;
|
|
37
|
+
const map = Array.isArray(data)
|
|
38
|
+
? data.reduce((acc, item) => {
|
|
39
|
+
if (!item || typeof item !== 'object')
|
|
40
|
+
return acc;
|
|
41
|
+
const record = item;
|
|
42
|
+
const id = record.id;
|
|
43
|
+
const options = record.options;
|
|
44
|
+
if (typeof id !== 'string')
|
|
45
|
+
return acc;
|
|
46
|
+
if (!options ||
|
|
47
|
+
typeof options !== 'object' ||
|
|
48
|
+
Array.isArray(options)) {
|
|
49
|
+
acc[id] = {};
|
|
50
|
+
return acc;
|
|
51
|
+
}
|
|
52
|
+
acc[id] = options;
|
|
53
|
+
return acc;
|
|
54
|
+
}, {})
|
|
55
|
+
: {};
|
|
56
|
+
return providerOptionsCache.set(map, 30_000);
|
|
57
|
+
};
|
|
58
|
+
const isValidQuotaCache = (snapshot) => {
|
|
59
|
+
// Guard against stale RightCode cache entries from pre-daily format.
|
|
60
|
+
if (snapshot.adapterID !== 'rightcode' || snapshot.status !== 'ok')
|
|
61
|
+
return true;
|
|
62
|
+
if (!snapshot.windows || snapshot.windows.length === 0)
|
|
63
|
+
return true;
|
|
64
|
+
const primary = snapshot.windows[0];
|
|
65
|
+
if (!primary.label.startsWith('Daily $'))
|
|
66
|
+
return false;
|
|
67
|
+
if (primary.showPercent !== false)
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
const getQuotaSnapshots = async (providerIDs, options) => {
|
|
72
|
+
const allowDefault = options?.allowDefault === true;
|
|
73
|
+
const [authMap, providerOptionsMap] = await Promise.all([
|
|
74
|
+
getAuthMap(),
|
|
75
|
+
getProviderOptionsMap(),
|
|
76
|
+
]);
|
|
77
|
+
const optionsForProvider = (providerID) => {
|
|
78
|
+
return (providerOptionsMap[providerID] ||
|
|
79
|
+
providerOptionsMap[deps.quotaRuntime.normalizeProviderID(providerID)]);
|
|
80
|
+
};
|
|
81
|
+
const directCandidates = providerIDs.map((providerID) => ({
|
|
82
|
+
providerID,
|
|
83
|
+
providerOptions: optionsForProvider(providerID),
|
|
84
|
+
}));
|
|
85
|
+
const defaultCandidates = allowDefault
|
|
86
|
+
? [
|
|
87
|
+
...Object.keys(providerOptionsMap).map((providerID) => ({
|
|
88
|
+
providerID,
|
|
89
|
+
providerOptions: providerOptionsMap[providerID],
|
|
90
|
+
})),
|
|
91
|
+
...listDefaultQuotaProviderIDs().map((providerID) => ({
|
|
92
|
+
providerID,
|
|
93
|
+
providerOptions: optionsForProvider(providerID),
|
|
94
|
+
})),
|
|
95
|
+
]
|
|
96
|
+
: [];
|
|
97
|
+
const rawCandidates = directCandidates.length
|
|
98
|
+
? directCandidates
|
|
99
|
+
: defaultCandidates;
|
|
100
|
+
const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
|
|
101
|
+
const dedupedCandidates = Array.from(matchedCandidates
|
|
102
|
+
.reduce((acc, candidate) => {
|
|
103
|
+
const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
104
|
+
if (!acc.has(key))
|
|
105
|
+
acc.set(key, candidate);
|
|
106
|
+
return acc;
|
|
107
|
+
}, new Map())
|
|
108
|
+
.values());
|
|
109
|
+
function authScopeFor(providerID, providerOptions) {
|
|
110
|
+
const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
|
|
111
|
+
const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
|
|
112
|
+
const adapterID = adapter?.id;
|
|
113
|
+
const candidates = [];
|
|
114
|
+
const push = (value) => {
|
|
115
|
+
if (!value)
|
|
116
|
+
return;
|
|
117
|
+
if (!candidates.includes(value))
|
|
118
|
+
candidates.push(value);
|
|
119
|
+
};
|
|
120
|
+
push(providerID);
|
|
121
|
+
push(normalized);
|
|
122
|
+
push(adapterID);
|
|
123
|
+
if (adapterID === 'github-copilot')
|
|
124
|
+
push('github-copilot-enterprise');
|
|
125
|
+
for (const key of candidates) {
|
|
126
|
+
const auth = authMap[key];
|
|
127
|
+
if (!auth)
|
|
128
|
+
continue;
|
|
129
|
+
if (key === 'openai' &&
|
|
130
|
+
auth.type === 'oauth' &&
|
|
131
|
+
typeof auth.accountId === 'string' &&
|
|
132
|
+
auth.accountId) {
|
|
133
|
+
return `${key}@${auth.accountId}`;
|
|
134
|
+
}
|
|
135
|
+
return key;
|
|
136
|
+
}
|
|
137
|
+
return 'none';
|
|
138
|
+
}
|
|
139
|
+
let cacheChanged = false;
|
|
140
|
+
const fetchSnapshot = (providerID, providerOptions) => {
|
|
141
|
+
const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
|
|
142
|
+
const cacheKey = `${baseKey}#${authScopeFor(providerID, providerOptions)}`;
|
|
143
|
+
const cached = deps.state.quotaCache[cacheKey];
|
|
144
|
+
if (cached &&
|
|
145
|
+
Date.now() - cached.checkedAt <= deps.config.quota.refreshMs) {
|
|
146
|
+
if (isValidQuotaCache(cached))
|
|
147
|
+
return Promise.resolve(cached);
|
|
148
|
+
delete deps.state.quotaCache[cacheKey];
|
|
149
|
+
cacheChanged = true;
|
|
150
|
+
}
|
|
151
|
+
const existing = inFlight.get(cacheKey);
|
|
152
|
+
if (existing)
|
|
153
|
+
return existing;
|
|
154
|
+
const promise = deps.quotaRuntime
|
|
155
|
+
.fetchQuotaSnapshot(providerID, authMap, deps.config, async (id, next) => {
|
|
156
|
+
await deps.client.auth
|
|
157
|
+
.set({
|
|
158
|
+
path: { id },
|
|
159
|
+
query: { directory: deps.directory },
|
|
160
|
+
body: next,
|
|
161
|
+
throwOnError: true,
|
|
162
|
+
})
|
|
163
|
+
.catch(swallow('getQuotaSnapshots:authSet'));
|
|
164
|
+
}, providerOptions)
|
|
165
|
+
.then((latest) => {
|
|
166
|
+
if (!latest)
|
|
167
|
+
return undefined;
|
|
168
|
+
deps.state.quotaCache[cacheKey] = latest;
|
|
169
|
+
cacheChanged = true;
|
|
170
|
+
return latest;
|
|
171
|
+
})
|
|
172
|
+
.finally(() => {
|
|
173
|
+
if (inFlight.get(cacheKey) === promise) {
|
|
174
|
+
inFlight.delete(cacheKey);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
inFlight.set(cacheKey, promise);
|
|
178
|
+
return promise;
|
|
179
|
+
};
|
|
180
|
+
const fetched = await Promise.all(dedupedCandidates.map(({ providerID, providerOptions }) => fetchSnapshot(providerID, providerOptions)));
|
|
181
|
+
const snapshots = fetched.filter((value) => Boolean(value));
|
|
182
|
+
snapshots.sort(quotaSort);
|
|
183
|
+
if (cacheChanged)
|
|
184
|
+
deps.scheduleSave();
|
|
185
|
+
return snapshots;
|
|
186
|
+
};
|
|
187
|
+
return { getQuotaSnapshots };
|
|
188
|
+
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -30,3 +30,5 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
|
|
|
30
30
|
dateKey: string;
|
|
31
31
|
state: SessionState;
|
|
32
32
|
}[]>;
|
|
33
|
+
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
34
|
+
export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
|
package/dist/storage.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { asBoolean, asNumber, debug, isRecord, swallow } from './helpers.js';
|
|
3
|
+
import { asBoolean, asNumber, debug, isRecord, mapConcurrent, swallow, } from './helpers.js';
|
|
4
4
|
import { discoverChunks, readDayChunk, safeWriteFile, writeDayChunk, } from './storage_chunks.js';
|
|
5
5
|
import { dateKeyFromTimestamp, dateKeysInRange, dateStartFromKey, isDateKey, normalizeTimestampMs, } from './storage_dates.js';
|
|
6
6
|
import { parseQuotaCache } from './storage_parse.js';
|
|
@@ -13,6 +13,10 @@ export const defaultConfig = {
|
|
|
13
13
|
width: 36,
|
|
14
14
|
showCost: true,
|
|
15
15
|
showQuota: true,
|
|
16
|
+
includeChildren: true,
|
|
17
|
+
childrenMaxDepth: 6,
|
|
18
|
+
childrenMaxSessions: 128,
|
|
19
|
+
childrenConcurrency: 5,
|
|
16
20
|
},
|
|
17
21
|
quota: {
|
|
18
22
|
refreshMs: 5 * 60 * 1000,
|
|
@@ -64,6 +68,10 @@ export async function loadConfig(paths) {
|
|
|
64
68
|
width: Math.max(20, Math.min(60, asNumber(sidebar.width, defaultConfig.sidebar.width))),
|
|
65
69
|
showCost: asBoolean(sidebar.showCost, defaultConfig.sidebar.showCost),
|
|
66
70
|
showQuota: asBoolean(sidebar.showQuota, defaultConfig.sidebar.showQuota),
|
|
71
|
+
includeChildren: asBoolean(sidebar.includeChildren, defaultConfig.sidebar.includeChildren),
|
|
72
|
+
childrenMaxDepth: Math.max(1, Math.min(32, Math.floor(asNumber(sidebar.childrenMaxDepth, defaultConfig.sidebar.childrenMaxDepth)))),
|
|
73
|
+
childrenMaxSessions: Math.max(0, Math.min(2000, Math.floor(asNumber(sidebar.childrenMaxSessions, defaultConfig.sidebar.childrenMaxSessions)))),
|
|
74
|
+
childrenConcurrency: Math.max(1, Math.min(10, Math.floor(asNumber(sidebar.childrenConcurrency, defaultConfig.sidebar.childrenConcurrency)))),
|
|
67
75
|
},
|
|
68
76
|
quota: {
|
|
69
77
|
refreshMs: Math.max(30_000, asNumber(quota.refreshMs, defaultConfig.quota.refreshMs)),
|
|
@@ -93,6 +101,7 @@ async function loadVersion2State(raw, statePath) {
|
|
|
93
101
|
const titleEnabled = asBoolean(raw.titleEnabled, true);
|
|
94
102
|
const quotaCache = parseQuotaCache(raw.quotaCache);
|
|
95
103
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
104
|
+
const hasSessionDateMap = Object.prototype.hasOwnProperty.call(raw, 'sessionDateMap');
|
|
96
105
|
const sessionDateMapRaw = isRecord(raw.sessionDateMap)
|
|
97
106
|
? raw.sessionDateMap
|
|
98
107
|
: {};
|
|
@@ -104,17 +113,25 @@ async function loadVersion2State(raw, statePath) {
|
|
|
104
113
|
acc[sessionID] = value;
|
|
105
114
|
return acc;
|
|
106
115
|
}, {});
|
|
116
|
+
const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
|
|
107
117
|
const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
// Only discover chunks when sessionDateMap is missing from state.
|
|
119
|
+
// If sessionDateMap exists (even empty), treat it as authoritative so we
|
|
120
|
+
// don't repeatedly load and evict historical sessions from disk.
|
|
121
|
+
const discoveredDateKeys = (!hasSessionDateMap && explicitDateKeys.length === 0) ||
|
|
122
|
+
(hasSessionDateMap &&
|
|
123
|
+
hadRawSessionDateMapEntries &&
|
|
124
|
+
explicitDateKeys.length === 0)
|
|
125
|
+
? await discoverChunks(rootPath)
|
|
126
|
+
: [];
|
|
111
127
|
const dateKeys = explicitDateKeys.length
|
|
112
128
|
? explicitDateKeys
|
|
113
129
|
: discoveredDateKeys;
|
|
114
|
-
const
|
|
130
|
+
const LOAD_CHUNKS_CONCURRENCY = 5;
|
|
131
|
+
const chunks = await mapConcurrent(dateKeys, LOAD_CHUNKS_CONCURRENCY, async (dateKey) => {
|
|
115
132
|
const sessions = await readDayChunk(rootPath, dateKey);
|
|
116
133
|
return [dateKey, sessions];
|
|
117
|
-
})
|
|
134
|
+
});
|
|
118
135
|
const sessions = {};
|
|
119
136
|
for (const [dateKey, chunkSessions] of chunks) {
|
|
120
137
|
for (const [sessionID, session] of Object.entries(chunkSessions)) {
|
|
@@ -175,8 +192,9 @@ export async function saveState(statePath, state, options) {
|
|
|
175
192
|
? session.createdAt
|
|
176
193
|
: Date.now();
|
|
177
194
|
session.createdAt = normalizedCreatedAt;
|
|
178
|
-
const dateKey = state.sessionDateMap[sessionID]
|
|
179
|
-
|
|
195
|
+
const dateKey = isDateKey(state.sessionDateMap[sessionID])
|
|
196
|
+
? state.sessionDateMap[sessionID]
|
|
197
|
+
: dateKeyFromTimestamp(normalizedCreatedAt);
|
|
180
198
|
state.sessionDateMap[sessionID] = dateKey;
|
|
181
199
|
// M11: skip sessions not in dirty set
|
|
182
200
|
if (!writeAll && dirtySet && !dirtySet.has(dateKey))
|
|
@@ -187,7 +205,28 @@ export async function saveState(statePath, state, options) {
|
|
|
187
205
|
}
|
|
188
206
|
}
|
|
189
207
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
190
|
-
|
|
208
|
+
if (!skipChunks) {
|
|
209
|
+
const keysToWrite = writeAll
|
|
210
|
+
? Object.keys(sessionsByDate)
|
|
211
|
+
: Array.from(dirtySet ?? []);
|
|
212
|
+
await Promise.all(keysToWrite
|
|
213
|
+
.map((dateKey) => {
|
|
214
|
+
if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
return (async () => {
|
|
218
|
+
const memorySessions = sessionsByDate[dateKey] || {};
|
|
219
|
+
const next = writeAll
|
|
220
|
+
? memorySessions
|
|
221
|
+
: {
|
|
222
|
+
...(await readDayChunk(rootPath, dateKey)),
|
|
223
|
+
...memorySessions,
|
|
224
|
+
};
|
|
225
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
226
|
+
})();
|
|
227
|
+
})
|
|
228
|
+
.filter((promise) => Boolean(promise)));
|
|
229
|
+
}
|
|
191
230
|
// M4: atomic state file write
|
|
192
231
|
await safeWriteFile(statePath, `${JSON.stringify({
|
|
193
232
|
version: 2,
|
|
@@ -195,19 +234,6 @@ export async function saveState(statePath, state, options) {
|
|
|
195
234
|
sessionDateMap: state.sessionDateMap,
|
|
196
235
|
quotaCache: state.quotaCache,
|
|
197
236
|
}, null, 2)}\n`);
|
|
198
|
-
if (skipChunks)
|
|
199
|
-
return;
|
|
200
|
-
const keysToWrite = writeAll
|
|
201
|
-
? Object.keys(sessionsByDate)
|
|
202
|
-
: Array.from(dirtySet ?? []);
|
|
203
|
-
await Promise.all(keysToWrite
|
|
204
|
-
.map((dateKey) => {
|
|
205
|
-
const sessions = sessionsByDate[dateKey];
|
|
206
|
-
if (!sessions)
|
|
207
|
-
return undefined;
|
|
208
|
-
return writeDayChunk(rootPath, dateKey, sessions);
|
|
209
|
-
})
|
|
210
|
-
.filter((promise) => Boolean(promise)));
|
|
211
237
|
}
|
|
212
238
|
// ─── Eviction (M2) ──────────────────────────────────────────────────────────
|
|
213
239
|
/**
|
|
@@ -264,14 +290,15 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
264
290
|
: new Set();
|
|
265
291
|
const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
|
|
266
292
|
if (diskDateKeys.length > 0) {
|
|
267
|
-
const
|
|
293
|
+
const RANGE_SCAN_CONCURRENCY = 5;
|
|
294
|
+
const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
|
|
268
295
|
const sessions = await readDayChunk(rootPath, dateKey);
|
|
269
296
|
return Object.entries(sessions).map(([sessionID, state]) => ({
|
|
270
297
|
sessionID,
|
|
271
298
|
dateKey,
|
|
272
299
|
state,
|
|
273
300
|
}));
|
|
274
|
-
})
|
|
301
|
+
});
|
|
275
302
|
for (const entry of chunkEntries.flat()) {
|
|
276
303
|
if (seenSessionIDs.has(entry.sessionID))
|
|
277
304
|
continue;
|
|
@@ -286,3 +313,14 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
286
313
|
}
|
|
287
314
|
return results;
|
|
288
315
|
}
|
|
316
|
+
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
317
|
+
export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
|
|
318
|
+
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
319
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
320
|
+
if (!Object.prototype.hasOwnProperty.call(sessions, sessionID))
|
|
321
|
+
return false;
|
|
322
|
+
const next = { ...sessions };
|
|
323
|
+
delete next[sessionID];
|
|
324
|
+
await writeDayChunk(rootPath, dateKey, next);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
package/dist/storage_chunks.js
CHANGED
|
@@ -5,6 +5,39 @@ import { debug, isRecord, swallow } from './helpers.js';
|
|
|
5
5
|
import { isDateKey } from './storage_dates.js';
|
|
6
6
|
import { parseSessionState } from './storage_parse.js';
|
|
7
7
|
import { chunkFilePath } from './storage_paths.js';
|
|
8
|
+
async function mkdirpNoSymlink(rootPath, dirPath) {
|
|
9
|
+
const rel = path.relative(rootPath, dirPath);
|
|
10
|
+
if (!rel || rel === '.')
|
|
11
|
+
return;
|
|
12
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
13
|
+
throw new Error(`refusing to mkdir outside root: ${dirPath}`);
|
|
14
|
+
}
|
|
15
|
+
let current = rootPath;
|
|
16
|
+
const parts = rel.split(path.sep).filter(Boolean);
|
|
17
|
+
for (const part of parts) {
|
|
18
|
+
current = path.join(current, part);
|
|
19
|
+
const stat = await fs.lstat(current).catch(() => undefined);
|
|
20
|
+
if (stat) {
|
|
21
|
+
if (stat.isSymbolicLink()) {
|
|
22
|
+
throw new Error(`refusing to write through symlink dir: ${current}`);
|
|
23
|
+
}
|
|
24
|
+
if (!stat.isDirectory()) {
|
|
25
|
+
throw new Error(`expected directory at ${current}`);
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
await fs.mkdir(current).catch((error) => {
|
|
30
|
+
const code = error.code;
|
|
31
|
+
if (code === 'EEXIST')
|
|
32
|
+
return;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
const created = await fs.lstat(current).catch(() => undefined);
|
|
36
|
+
if (!created || created.isSymbolicLink() || !created.isDirectory()) {
|
|
37
|
+
throw new Error(`unsafe directory created at ${current}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
8
41
|
/** P2: Simple LRU cache for loaded chunks. */
|
|
9
42
|
class ChunkCache {
|
|
10
43
|
cache = new Map();
|
|
@@ -41,10 +74,17 @@ class ChunkCache {
|
|
|
41
74
|
}
|
|
42
75
|
const chunkCache = new ChunkCache();
|
|
43
76
|
export async function readDayChunk(rootPath, dateKey) {
|
|
77
|
+
if (!isDateKey(dateKey))
|
|
78
|
+
return {};
|
|
44
79
|
const cached = chunkCache.get(dateKey);
|
|
45
80
|
if (cached)
|
|
46
81
|
return cached;
|
|
47
82
|
const filePath = chunkFilePath(rootPath, dateKey);
|
|
83
|
+
const stat = await fs.lstat(filePath).catch(() => undefined);
|
|
84
|
+
if (stat?.isSymbolicLink()) {
|
|
85
|
+
debug(`refusing to read symlink chunk: ${filePath}`);
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
48
88
|
const parsed = await fs
|
|
49
89
|
.readFile(filePath, 'utf8')
|
|
50
90
|
.then((value) => JSON.parse(value))
|
|
@@ -76,6 +116,12 @@ export async function safeWriteFile(filePath, content) {
|
|
|
76
116
|
debug(message);
|
|
77
117
|
throw new Error(message);
|
|
78
118
|
}
|
|
119
|
+
const dirStat = await fs.lstat(path.dirname(filePath)).catch(() => undefined);
|
|
120
|
+
if (dirStat?.isSymbolicLink()) {
|
|
121
|
+
const message = `refusing to write through symlink dir: ${path.dirname(filePath)}`;
|
|
122
|
+
debug(message);
|
|
123
|
+
throw new Error(message);
|
|
124
|
+
}
|
|
79
125
|
// M4: atomic write via temp + rename
|
|
80
126
|
const dir = path.dirname(filePath);
|
|
81
127
|
const name = path.basename(filePath);
|
|
@@ -109,8 +155,25 @@ export async function safeWriteFile(filePath, content) {
|
|
|
109
155
|
: new Error(`safeWriteFile failed for ${filePath}`);
|
|
110
156
|
}
|
|
111
157
|
export async function writeDayChunk(rootPath, dateKey, sessions) {
|
|
158
|
+
if (!isDateKey(dateKey)) {
|
|
159
|
+
throw new Error(`invalid dateKey: ${dateKey}`);
|
|
160
|
+
}
|
|
112
161
|
const filePath = chunkFilePath(rootPath, dateKey);
|
|
113
|
-
await fs.
|
|
162
|
+
const rootStat = await fs.lstat(rootPath).catch(() => undefined);
|
|
163
|
+
if (rootStat?.isSymbolicLink()) {
|
|
164
|
+
throw new Error(`refusing to write through symlink dir: ${rootPath}`);
|
|
165
|
+
}
|
|
166
|
+
if (rootStat && !rootStat.isDirectory()) {
|
|
167
|
+
throw new Error(`expected directory at ${rootPath}`);
|
|
168
|
+
}
|
|
169
|
+
await fs.mkdir(rootPath, { recursive: true });
|
|
170
|
+
const createdRoot = await fs.lstat(rootPath).catch(() => undefined);
|
|
171
|
+
if (!createdRoot ||
|
|
172
|
+
createdRoot.isSymbolicLink() ||
|
|
173
|
+
!createdRoot.isDirectory()) {
|
|
174
|
+
throw new Error(`unsafe chunk root at ${rootPath}`);
|
|
175
|
+
}
|
|
176
|
+
await mkdirpNoSymlink(rootPath, path.dirname(filePath));
|
|
114
177
|
const chunk = {
|
|
115
178
|
version: 1,
|
|
116
179
|
dateKey,
|
|
@@ -126,11 +189,21 @@ export async function discoverChunks(rootPath) {
|
|
|
126
189
|
if (!/^\d{4}$/.test(year))
|
|
127
190
|
continue;
|
|
128
191
|
const yearPath = path.join(rootPath, year);
|
|
192
|
+
const yearStat = await fs.lstat(yearPath).catch(() => undefined);
|
|
193
|
+
if (!yearStat || yearStat.isSymbolicLink() || !yearStat.isDirectory()) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
129
196
|
const months = await fs.readdir(yearPath).catch(() => []);
|
|
130
197
|
for (const month of months) {
|
|
131
198
|
if (!/^\d{2}$/.test(month))
|
|
132
199
|
continue;
|
|
133
200
|
const monthPath = path.join(yearPath, month);
|
|
201
|
+
const monthStat = await fs.lstat(monthPath).catch(() => undefined);
|
|
202
|
+
if (!monthStat ||
|
|
203
|
+
monthStat.isSymbolicLink() ||
|
|
204
|
+
!monthStat.isDirectory()) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
134
207
|
const days = await fs.readdir(monthPath).catch(() => []);
|
|
135
208
|
for (const dayFile of days) {
|
|
136
209
|
const match = dayFile.match(/^(\d{2})\.json$/);
|
package/dist/storage_parse.js
CHANGED
|
@@ -55,9 +55,16 @@ function parseCachedUsage(value) {
|
|
|
55
55
|
function parseCursor(value) {
|
|
56
56
|
if (!isRecord(value))
|
|
57
57
|
return undefined;
|
|
58
|
+
const idsRaw = value.lastMessageIdsAtTime;
|
|
59
|
+
const lastMessageIdsAtTime = Array.isArray(idsRaw)
|
|
60
|
+
? idsRaw.filter((item) => typeof item === 'string' && !!item)
|
|
61
|
+
: undefined;
|
|
58
62
|
return {
|
|
59
63
|
lastMessageId: typeof value.lastMessageId === 'string' ? value.lastMessageId : undefined,
|
|
60
64
|
lastMessageTime: asNumber(value.lastMessageTime),
|
|
65
|
+
lastMessageIdsAtTime: lastMessageIdsAtTime && lastMessageIdsAtTime.length
|
|
66
|
+
? Array.from(new Set(lastMessageIdsAtTime)).sort()
|
|
67
|
+
: undefined,
|
|
61
68
|
};
|
|
62
69
|
}
|
|
63
70
|
export function parseSessionState(value) {
|
|
@@ -72,6 +79,7 @@ export function parseSessionState(value) {
|
|
|
72
79
|
return {
|
|
73
80
|
...title,
|
|
74
81
|
createdAt,
|
|
82
|
+
parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
|
|
75
83
|
usage: parseCachedUsage(value.usage),
|
|
76
84
|
cursor: parseCursor(value.cursor),
|
|
77
85
|
};
|
package/dist/storage_paths.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* This applies on all platforms including Windows and macOS.
|
|
7
7
|
*
|
|
8
8
|
* S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
|
|
9
|
+
* OPENCODE_QUOTA_DATA_HOME overrides the full data directory path.
|
|
9
10
|
*/
|
|
10
11
|
export declare function resolveOpencodeDataDir(): string;
|
|
11
12
|
export declare function stateFilePath(dataDir: string): string;
|