Compare commits

..

2 Commits

Author SHA1 Message Date
koriweb 98d533f045 chore(records): .astra 런타임 자산 동기화 (v2.2.297 후)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:49:06 +09:00
koriweb 443488a3b6 v2.2.297: stocks 목록 클라우드 백업 — Sheets _backup 탭 자동 백업 + /stocks backup·restore
로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본
JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks
restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가,
writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:48:59 +09:00
17 changed files with 301 additions and 24 deletions
@@ -1,5 +1,5 @@
{ {
"result": "직답 결과 — single-pass mock 응답입니다.", "result": "직답 결과 — single-pass mock 응답입니다.",
"createdAt": 1783247674941, "createdAt": 1783259143996,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "---\nid: wiki_on\ndate: 2026-07-05T10:34:34.941Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (11ms)\n", "result": "---\nid: wiki_on\ndate: 2026-07-05T13:45:43.997Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n",
"createdAt": 1783247674941, "createdAt": 1783259143997,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "wiki_on", "missionId": "wiki_on",
"status": "completed", "status": "completed",
"startTime": "2026-07-05T10:34:34.929Z", "startTime": "2026-07-05T13:45:43.984Z",
"totalElapsedMs": 12, "totalElapsedMs": 13,
"results": { "results": {
"direct": "직답 결과 — single-pass mock 응답입니다." "direct": "직답 결과 — single-pass mock 응답입니다."
}, },
@@ -12,16 +12,16 @@
{ {
"from": "idle", "from": "idle",
"to": "direct", "to": "direct",
"durationMs": 11, "durationMs": 12,
"message": "답변 작성 중... (단일 호출 fast-path)", "message": "답변 작성 중... (단일 호출 fast-path)",
"ts": "2026-07-05T10:34:34.940Z" "ts": "2026-07-05T13:45:43.996Z"
}, },
{ {
"from": "direct", "from": "direct",
"to": "completed", "to": "completed",
"durationMs": 1, "durationMs": 1,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-07-05T10:34:34.941Z" "ts": "2026-07-05T13:45:43.997Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1783247682033, "createdAt": 1783259150864,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "Final report with inconsistencies. This should be long enough to pass validation.", "result": "Final report with inconsistencies. This should be long enough to pass validation.",
"createdAt": 1783247682033, "createdAt": 1783259150864,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"createdAt": 1783247682031, "createdAt": 1783259150863,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,5 +1,5 @@
{ {
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
"createdAt": 1783247682032, "createdAt": 1783259150863,
"modelVersion": "unknown" "modelVersion": "unknown"
} }
@@ -1,8 +1,8 @@
{ {
"missionId": "stress_conflict_1783247682020", "missionId": "stress_conflict_1783259150850",
"status": "completed", "status": "completed",
"startTime": "2026-07-05T10:34:42.020Z", "startTime": "2026-07-05T13:45:50.850Z",
"totalElapsedMs": 13, "totalElapsedMs": 14,
"results": { "results": {
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", "outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", "section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
@@ -14,30 +14,30 @@
{ {
"from": "idle", "from": "idle",
"to": "outline", "to": "outline",
"durationMs": 11, "durationMs": 12,
"message": "답변 구조 잡는 중...", "message": "답변 구조 잡는 중...",
"ts": "2026-07-05T10:34:42.031Z" "ts": "2026-07-05T13:45:50.862Z"
}, },
{ {
"from": "outline", "from": "outline",
"to": "section", "to": "section",
"durationMs": 1, "durationMs": 1,
"message": "본문 작성 중...", "message": "본문 작성 중...",
"ts": "2026-07-05T10:34:42.032Z" "ts": "2026-07-05T13:45:50.863Z"
}, },
{ {
"from": "section", "from": "section",
"to": "polish", "to": "polish",
"durationMs": 0, "durationMs": 0,
"message": "최종 다듬기 중...", "message": "최종 다듬기 중...",
"ts": "2026-07-05T10:34:42.032Z" "ts": "2026-07-05T13:45:50.863Z"
}, },
{ {
"from": "polish", "from": "polish",
"to": "completed", "to": "completed",
"durationMs": 1, "durationMs": 1,
"message": "미션 완료", "message": "미션 완료",
"ts": "2026-07-05T10:34:42.033Z" "ts": "2026-07-05T13:45:50.864Z"
} }
], ],
"resilienceMetrics": { "resilienceMetrics": {
+8
View File
@@ -1,5 +1,13 @@
# Astra Patch Notes # Astra Patch Notes
## v2.2.297 (2026-07-05)
### ☁️ stocks 목록 클라우드 백업 — 컴퓨터 바꿔도 워치리스트 유지
stocks.json 이 로컬 워크스페이스에만 있어 기기를 바꾸면 목록이 사라지던 문제 해결. 기존 Sheet1~3 sync 는 분류 표시용이라 복원이 불가능했다.
- **자동 백업**: 목록이 바뀔 때마다(add/remove/check/discover 자동편입/워처 가격갱신 — writeStocksStore 성공 시 리스너 발화) 30초 디바운스 후 기존 spreadsheet(`g1nation.stocks.spreadsheetId`)의 `_backup` 탭에 **원본 JSON 그대로** 백업. 탭은 없으면 자동 생성(batchUpdate addSheet), 목록이 줄어도 잔여 행이 안 남게 클리어 후 덮어쓰기. `g1nation.stocks.autoBackup`(기본 ON)으로 끄기 가능.
- **`/stocks backup`**: 수동 즉시 백업. **`/stocks restore`**: 새 컴퓨터에서 한 번으로 복원(로컬 목록 교체, silent write 로 되백업 루프 차단, 손상 행은 skip).
- sheetsApi 에 `ensureSheetTab`(탭 존재 확인+생성) / `clearSheetRange`(values:clear) 추가 — OAuth·인프라는 기존 calendar/sheets 공유 그대로.
검증: tsc 무오류 + jest 818 통과 + esbuild 정상.
## v2.2.296 (2026-07-05) ## v2.2.296 (2026-07-05)
### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색 ### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색
로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다. 로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다.
+6 -1
View File
@@ -2,7 +2,7 @@
"name": "astra", "name": "astra",
"displayName": "Astra", "displayName": "Astra",
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
"version": "2.2.296", "version": "2.2.297",
"publisher": "g1nation", "publisher": "g1nation",
"license": "MIT", "license": "MIT",
"icon": "assets/icon.png", "icon": "assets/icon.png",
@@ -1238,6 +1238,11 @@
"default": "", "default": "",
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)." "markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
}, },
"g1nation.stocks.autoBackup": {
"type": "boolean",
"default": true,
"markdownDescription": "**stocks.json 자동 클라우드 백업** (v2.2.297) — 목록이 바뀔 때마다(추가/제거/가격갱신/발굴 편입) 30초 디바운스 후 `g1nation.stocks.spreadsheetId` spreadsheet 의 `_backup` 탭에 원본 JSON 그대로 백업. 컴퓨터를 바꿔도 `/stocks restore` 한 번으로 복원. spreadsheetId 미설정이면 조용히 skip. 수동 백업은 `/stocks backup`."
},
"g1nation.stocks.sheetSwing": { "g1nation.stocks.sheetSwing": {
"type": "string", "type": "string",
"default": "Sheet1", "default": "Sheet1",
+3 -1
View File
@@ -39,7 +39,7 @@ import { getBrainTokenIndex, clearBrainTokenIndex } from './retrieval';
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers'; import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup'; import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup';
import { runInitialSetup } from './extension/initialSetup'; import { runInitialSetup } from './extension/initialSetup';
import { startStocksWatcher } from './features/stocks'; import { startStocksWatcher, registerStocksAutoBackup } from './features/stocks';
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
import { startEmailWatcher } from './features/email/emailWatcher'; import { startEmailWatcher } from './features/email/emailWatcher';
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap'; import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
@@ -327,6 +327,8 @@ export async function activate(context: vscode.ExtensionContext) {
// Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거. // Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거.
// disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup. // disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup.
context.subscriptions.push(startStocksWatcher(context)); context.subscriptions.push(startStocksWatcher(context));
// Stocks 자동 백업 — stocks.json 변경 시 Sheets `_backup` 탭으로 (v2.2.297).
context.subscriptions.push(registerStocksAutoBackup(context));
// Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송. // Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송.
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만). // 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
+2
View File
@@ -2,6 +2,8 @@ export {
readSheetRange, readSheetRange,
writeSheetRange, writeSheetRange,
appendSheetRows, appendSheetRows,
clearSheetRange,
ensureSheetTab,
parseTsvBody, parseTsvBody,
valuesToMarkdownTable, valuesToMarkdownTable,
SheetCell, SheetCell,
+69
View File
@@ -127,6 +127,75 @@ export async function appendSheetRows(
} }
} }
/**
* range 의 값 지우기 — 셀 서식은 유지, 값만 제거.
* 백업 탭 덮어쓰기 전 잔여 행 제거용 (목록이 줄었을 때 아래 행이 남지 않게).
*/
export async function clearSheetRange(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
): Promise<{ ok: true } | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:clear`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${tok.accessToken}` },
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
return { ok: true };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/**
* title 이름의 탭이 spreadsheet 에 없으면 batchUpdate(addSheet) 로 생성.
* 이미 있으면 no-op. 백업 탭처럼 사용자가 미리 만들어 두지 않는 탭에 쓰기 전 호출.
*/
export async function ensureSheetTab(
context: vscode.ExtensionContext,
spreadsheetId: string,
title: string,
): Promise<{ ok: true; created: boolean } | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!title.trim()) return { ok: false, error: 'sheet title 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
try {
const metaUrl = `${API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`;
const metaRes = await fetch(metaUrl, {
headers: { Authorization: `Bearer ${tok.accessToken}` },
signal: AbortSignal.timeout(15000),
});
const meta: any = await metaRes.json().catch(() => ({}));
if (!metaRes.ok) return { ok: false, error: meta?.error?.message || `HTTP ${metaRes.status}` };
const titles: string[] = (meta.sheets ?? []).map((s: any) => s?.properties?.title).filter(Boolean);
if (titles.includes(title)) return { ok: true, created: false };
const updRes = await fetch(`${API_BASE}/${encodeURIComponent(spreadsheetId)}:batchUpdate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${tok.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ requests: [{ addSheet: { properties: { title } } }] }),
signal: AbortSignal.timeout(15000),
});
const upd: any = await updRes.json().catch(() => ({}));
if (!updRes.ok) return { ok: false, error: upd?.error?.message || `HTTP ${updRes.status}` };
return { ok: true, created: true };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ────────────── // ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
/** /**
+133
View File
@@ -0,0 +1,133 @@
import * as vscode from 'vscode';
import { clearSheetRange, ensureSheetTab, readSheetRange, writeSheetRange, type SheetValues } from '../sheets';
import { logError, logInfo } from '../../utils';
import { onStocksStoreChanged, readStocksStore, writeStocksStore } from './stocksStore';
import type { Stock } from './types';
/**
* stocks.json 원본 클라우드 백업 — Google Sheets `_backup` 탭 (v2.2.297).
*
* 문제: 목록의 source of truth 가 로컬 `.astra/stocks.json` 뿐이라 컴퓨터를 바꾸면
* 데이터가 사라진다. Sheet1~3 sync 는 분류 *표시용* 이라 복원 불가.
*
* 해결: 같은 spreadsheet(`g1nation.stocks.spreadsheetId`)에 `_backup` 탭을 자동
* 생성하고, 종목당 한 행으로 **원본 JSON 그대로** 백업. 새 컴퓨터에서는
* `/stocks restore` 한 번으로 복원.
*
* 백업 시점: writeStocksStore 성공 → onStocksStoreChanged → 디바운스 30초 후 1회.
* (add/remove/check/discover 자동편입/워처 가격갱신 전부 이 경로를 지남.)
*
* 레이아웃:
* A1: BACKUP_MARKER B1: ISO timestamp C1: 종목 수
* A2~: 심볼 B2~: 이름 C2~: JSON.stringify(stock)
* 심볼/이름 컬럼은 사람이 시트에서 훑어보기용 — 복원은 C열 JSON 만 사용.
*/
const BACKUP_TAB = '_backup';
const BACKUP_MARKER = 'ASTRA_STOCKS_BACKUP_V1';
const DEBOUNCE_MS = 30_000;
function getSpreadsheetId(): string {
return (vscode.workspace.getConfiguration('g1nation').get<string>('stocks.spreadsheetId') || '').trim();
}
function autoBackupEnabled(): boolean {
return vscode.workspace.getConfiguration('g1nation').get<boolean>('stocks.autoBackup', true) !== false;
}
/** 지금 즉시 백업 1회. spreadsheetId 미설정이면 skip (ok:false + reason). */
export async function backupToSheet(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; reason?: string; count?: number }> {
const spreadsheetId = getSpreadsheetId();
if (!spreadsheetId) {
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
}
const store = readStocksStore();
const ensured = await ensureSheetTab(context, spreadsheetId, BACKUP_TAB);
if (!ensured.ok) return { ok: false, reason: `백업 탭 확인 실패: ${ensured.error}` };
if (ensured.created) logInfo(`Stocks backup: '${BACKUP_TAB}' 탭 생성.`);
// 목록이 줄었을 때 잔여 행이 남지 않게 먼저 클리어. 클리어 실패는 치명적이지
// 않으므로 (덮어쓰기는 어차피 진행) 로그만 남기고 계속.
const cleared = await clearSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A:C`);
if (!cleared.ok) logError('Stocks backup: 기존 행 클리어 실패 — 덮어쓰기로 계속.', { error: cleared.error });
const rows: SheetValues = [[BACKUP_MARKER, new Date().toISOString(), store.length]];
for (const s of store) {
rows.push([s., s., JSON.stringify(s)]);
}
const wrote = await writeSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1`, rows);
if (!wrote.ok) return { ok: false, reason: `백업 쓰기 실패: ${wrote.error}` };
logInfo('Stocks backup: 완료.', { count: store.length });
return { ok: true, count: store.length };
}
/**
* `_backup` 탭에서 목록 복원 — 로컬 stocks.json 을 백업 내용으로 **교체**.
* silent write — 복원 직후 같은 내용을 되백업하는 루프 방지.
*/
export async function restoreFromSheet(
context: vscode.ExtensionContext,
): Promise<{ ok: boolean; reason?: string; count?: number; backedUpAt?: string; previousCount?: number }> {
const spreadsheetId = getSpreadsheetId();
if (!spreadsheetId) {
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
}
const read = await readSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1:C`);
if (!read.ok) return { ok: false, reason: `백업 탭 읽기 실패: ${read.error} — 아직 백업한 적이 없다면 /stocks backup 먼저.` };
const values = read.values;
const header = values[0] ?? [];
if (String(header[0] ?? '') !== BACKUP_MARKER) {
return { ok: false, reason: `'${BACKUP_TAB}' 탭이 백업 형식이 아닙니다 (헤더 마커 불일치).` };
}
const backedUpAt = String(header[1] ?? '');
const restored: Stock[] = [];
for (const row of values.slice(1)) {
const raw = String(row[2] ?? '').trim();
if (!raw) continue;
try {
const s = JSON.parse(raw);
if (s && typeof s === 'object' && typeof s. === 'string' && typeof s. === 'string') {
restored.push(s as Stock);
}
} catch { /* 손상 행은 skip — 나머지는 살린다 */ }
}
if (restored.length === 0) {
return { ok: false, reason: '백업에 유효한 종목이 없습니다 — 로컬 목록을 건드리지 않았습니다.' };
}
const previousCount = readStocksStore().length;
if (!writeStocksStore(restored, { silent: true })) {
return { ok: false, reason: 'stocks.json 쓰기 실패 (워크스페이스 확인).' };
}
logInfo('Stocks restore: 완료.', { count: restored.length, backedUpAt, previousCount });
return { ok: true, count: restored.length, backedUpAt, previousCount };
}
/**
* 자동 백업 배선 — extension activate 에서 1회 호출.
* store 변경 → 30초 디바운스 → backupToSheet. spreadsheetId 미설정/설정 OFF 면 조용히 skip.
*/
export function registerStocksAutoBackup(context: vscode.ExtensionContext): vscode.Disposable {
let timer: NodeJS.Timeout | undefined;
const sub = onStocksStoreChanged(() => {
if (!autoBackupEnabled() || !getSpreadsheetId()) return;
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
timer = undefined;
const r = await backupToSheet(context);
if (!r.ok) logError('Stocks 자동 백업 실패.', { reason: r.reason });
}, DEBOUNCE_MS);
});
return {
dispose: () => {
sub.dispose();
if (timer) { clearTimeout(timer); timer = undefined; }
},
};
}
+1
View File
@@ -1,2 +1,3 @@
export { startStocksWatcher, runOnceNow } from './stocksWatcher'; export { startStocksWatcher, runOnceNow } from './stocksWatcher';
export { handleStocksCommand } from './slashStocks'; export { handleStocksCommand } from './slashStocks';
export { registerStocksAutoBackup } from './backupSync';
+30
View File
@@ -9,6 +9,7 @@ import { AIService } from '../../core/services';
import { classifyAll } from './signalClassifier'; import { classifyAll } from './signalClassifier';
import { writeStocksStore } from './stocksStore'; import { writeStocksStore } from './stocksStore';
import { syncToSheets } from './sheetsSync'; import { syncToSheets } from './sheetsSync';
import { backupToSheet, restoreFromSheet } from './backupSync';
import { judgeStock } from './llmJudge'; import { judgeStock } from './llmJudge';
import { sendStocksReport, buildReportText } from './telegramReport'; import { sendStocksReport, buildReportText } from './telegramReport';
import { runOnceNow } from './stocksWatcher'; import { runOnceNow } from './stocksWatcher';
@@ -27,6 +28,8 @@ import type { ClassifiedStock, Stock } from './types';
* /stocks check — 현재가 갱신 (Yahoo) * /stocks check — 현재가 갱신 (Yahoo)
* /stocks signal — 매수사정권 종목만 * /stocks signal — 매수사정권 종목만
* /stocks sync — Google Sheets 동기화 * /stocks sync — Google Sheets 동기화
* /stocks backup — 원본 목록을 Sheets `_backup` 탭에 백업
* /stocks restore — `_backup` 탭에서 목록 복원 (기기 이전용)
* /stocks add <심볼> <이름> [투자성향] * /stocks add <심볼> <이름> [투자성향]
* /stocks remove <심볼> * /stocks remove <심볼>
* /stocks judge <심볼> — LLM 4-criteria 평가 * /stocks judge <심볼> — LLM 4-criteria 평가
@@ -116,6 +119,25 @@ async function cmdSync(view: Webview | undefined, context: vscode.ExtensionConte
} }
} }
async function cmdBackup(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n☁️ Google Sheets `_backup` 탭으로 백업 중...\n');
const r = await backupToSheet(context);
chunk(view, r.ok
? `\n✅ 백업 완료: ${r.count}개 종목. 새 컴퓨터에서는 \`/stocks restore\` 로 복원하세요.\n`
: `\n❌ 백업 실패: ${r.reason}\n`);
}
async function cmdRestore(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
chunk(view, '\n☁️ Google Sheets `_backup` 탭에서 복원 중...\n');
const r = await restoreFromSheet(context);
if (!r.ok) {
chunk(view, `\n❌ 복원 실패: ${r.reason}\n`);
return;
}
const when = r.backedUpAt ? ` (백업 시각: ${r.backedUpAt})` : '';
chunk(view, `\n✅ 복원 완료: ${r.count}개 종목${when} — 기존 로컬 ${r.previousCount}개를 교체했습니다.\n`);
}
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> { async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
const parts = arg.split(/\s+/); const parts = arg.split(/\s+/);
if (parts.length < 2) { if (parts.length < 2) {
@@ -645,6 +667,8 @@ function cmdHelp(view: Webview | undefined): void {
' `/stocks check` — 현재가 갱신', ' `/stocks check` — 현재가 갱신',
' `/stocks signal` — 매수사정권 종목만', ' `/stocks signal` — 매수사정권 종목만',
' `/stocks sync` — Google Sheets 동기화', ' `/stocks sync` — Google Sheets 동기화',
' `/stocks backup` — 목록 원본을 Sheets `_backup` 탭에 백업 (변경 시 자동 백업도 됨)',
' `/stocks restore` — `_backup` 탭에서 목록 복원 (새 컴퓨터 이전용)',
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)', ' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
' `/stocks remove <심볼>` — 종목 제거', ' `/stocks remove <심볼>` — 종목 제거',
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)', ' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
@@ -683,6 +707,12 @@ export async function handleStocksCommand(
case 'sync': case 'sync':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; } if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
await cmdSync(view, context); return true; await cmdSync(view, context); return true;
case 'backup':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (backup 불가).\n'); return true; }
await cmdBackup(view, context); return true;
case 'restore':
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (restore 불가).\n'); return true; }
await cmdRestore(view, context); return true;
case 'add': await cmdAdd(rest, view); return true; case 'add': await cmdAdd(rest, view); return true;
case 'remove': case 'rm': await cmdRemove(rest, view); return true; case 'remove': case 'rm': await cmdRemove(rest, view); return true;
case 'judge': await cmdJudge(rest, view); return true; case 'judge': await cmdJudge(rest, view); return true;
+29 -2
View File
@@ -17,6 +17,28 @@ import type { Stock, StocksStore } from './types';
const STORE_REL_PATH = '.astra/stocks.json'; const STORE_REL_PATH = '.astra/stocks.json';
/**
* 저장 성공 시 알림 리스너 (v2.2.297) — 모든 변경 경로(add/remove/update/check/
* discover 자동편입/워처 가격갱신)가 writeStocksStore 한 곳을 지나므로, 여기서
* fire 하면 Sheets 자동 백업이 변경을 빠짐없이 따라간다.
* 리스너 예외는 삼킨다 — 백업 실패가 저장 자체를 깨면 안 됨.
*/
type StoreChangeListener = () => void;
const changeListeners = new Set<StoreChangeListener>();
export function onStocksStoreChanged(listener: StoreChangeListener): { dispose(): void } {
changeListeners.add(listener);
return { dispose: () => { changeListeners.delete(listener); } };
}
function fireStoreChanged(): void {
for (const l of changeListeners) {
try { l(); } catch (e: any) {
logError('stocks store 변경 리스너 예외.', { error: e?.message ?? String(e) });
}
}
}
export function getStocksFilePath(): string | null { export function getStocksFilePath(): string | null {
const folders = vscode.workspace.workspaceFolders; const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return null; if (!folders || folders.length === 0) return null;
@@ -41,8 +63,12 @@ export function readStocksStore(): StocksStore {
} }
} }
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */ /**
export function writeStocksStore(store: StocksStore): boolean { * Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내).
* opts.silent: 변경 리스너 미발화 — restore 처럼 백업에서 방금 읽어온 내용을
* 다시 백업하는 루프를 끊을 때만 사용.
*/
export function writeStocksStore(store: StocksStore, opts?: { silent?: boolean }): boolean {
const filePath = getStocksFilePath(); const filePath = getStocksFilePath();
if (!filePath) { if (!filePath) {
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.'); logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
@@ -54,6 +80,7 @@ export function writeStocksStore(store: StocksStore): boolean {
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`; const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8'); fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, filePath); fs.renameSync(tmp, filePath);
if (!opts?.silent) fireStoreChanged();
return true; return true;
} catch (e: any) { } catch (e: any) {
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) }); logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });