mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Centralize date/time formatting utilities (#11831)
This commit is contained in:
64
.github/instructions/copilot.instructions.md
vendored
Normal file
64
.github/instructions/copilot.instructions.md
vendored
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# OpenClaw Codebase Patterns
|
||||||
|
|
||||||
|
**Always reuse existing code - no redundancy!**
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
|
||||||
|
- **Language**: TypeScript (ESM, strict mode)
|
||||||
|
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
|
||||||
|
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
|
||||||
|
- **Tests**: Vitest with V8 coverage
|
||||||
|
- **CLI Framework**: Commander + clack/prompts
|
||||||
|
- **Build**: tsdown (outputs to `dist/`)
|
||||||
|
|
||||||
|
## Anti-Redundancy Rules
|
||||||
|
|
||||||
|
- Avoid files that just re-export from another file. Import directly from the original source.
|
||||||
|
- If a function already exists, import it - do NOT create a duplicate in another file.
|
||||||
|
- Before creating any formatter, utility, or helper, search for existing implementations first.
|
||||||
|
|
||||||
|
## Source of Truth Locations
|
||||||
|
|
||||||
|
### Formatting Utilities (`src/infra/`)
|
||||||
|
|
||||||
|
- **Time formatting**: `src\infra\format-time`
|
||||||
|
|
||||||
|
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
|
||||||
|
|
||||||
|
### Terminal Output (`src/terminal/`)
|
||||||
|
|
||||||
|
- Tables: `src/terminal/table.ts` (`renderTable`)
|
||||||
|
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
|
||||||
|
- Progress: `src/cli/progress.ts` (spinners, progress bars)
|
||||||
|
|
||||||
|
### CLI Patterns
|
||||||
|
|
||||||
|
- CLI option wiring: `src/cli/`
|
||||||
|
- Commands: `src/commands/`
|
||||||
|
- Dependency injection via `createDefaultDeps`
|
||||||
|
|
||||||
|
## Import Conventions
|
||||||
|
|
||||||
|
- Use `.js` extension for cross-package imports (ESM)
|
||||||
|
- Direct imports only - no re-export wrapper files
|
||||||
|
- Types: `import type { X }` for type-only imports
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- TypeScript (ESM), strict typing, avoid `any`
|
||||||
|
- Keep files under ~700 LOC - extract helpers when larger
|
||||||
|
- Colocated tests: `*.test.ts` next to source files
|
||||||
|
- Run `pnpm check` before commits (lint + format)
|
||||||
|
- Run `pnpm tsgo` for type checking
|
||||||
|
|
||||||
|
## Stack & Commands
|
||||||
|
|
||||||
|
- **Package manager**: pnpm (`pnpm install`)
|
||||||
|
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
|
||||||
|
- **Type-check**: `pnpm tsgo`
|
||||||
|
- **Lint/format**: `pnpm check`
|
||||||
|
- **Tests**: `pnpm test`
|
||||||
|
- **Build**: `pnpm build`
|
||||||
|
|
||||||
|
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -72,7 +72,7 @@ USER.md
|
|||||||
.serena/
|
.serena/
|
||||||
|
|
||||||
# Agent credentials and memory (NEVER COMMIT)
|
# Agent credentials and memory (NEVER COMMIT)
|
||||||
memory/
|
/memory/
|
||||||
.agent/*.json
|
.agent/*.json
|
||||||
!.agent/workflows/
|
!.agent/workflows/
|
||||||
local/
|
local/
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Contributing to the OpenClaw Threat Model
|
# Contributing to the OpenClaw Threat Model
|
||||||
|
|
||||||
Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert.
|
Thanks for helping make OpenClaw more secure. This threat model is a living document and we welcome contributions from anyone - you don't need to be a security expert.
|
||||||
|
|
||||||
## Ways to Contribute
|
## Ways to Contribute
|
||||||
|
|
||||||
### Add a Threat
|
### Add a Threat
|
||||||
|
|
||||||
Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/trust](https://github.com/openclaw/trust/issues) and describe it in your own words. You don't need to know any frameworks or fill in every field - just describe the scenario.
|
Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/trust](https://github.com/openclaw/trust/issues) and describe it in your own words. You don't need to know any frameworks or fill in every field - just describe the scenario.
|
||||||
|
|
||||||
**Helpful to include (but not required):**
|
**Helpful to include (but not required):**
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@ Spotted an attack vector or risk we haven't covered? Open an issue on [openclaw/
|
|||||||
- How severe you think it is (low / medium / high / critical)
|
- How severe you think it is (low / medium / high / critical)
|
||||||
- Any links to related research, CVEs, or real-world examples
|
- Any links to related research, CVEs, or real-world examples
|
||||||
|
|
||||||
We'll handle the ATLAS mapping, threat IDs, and risk assessment during review. If you want to include those details, great - but it's not expected.
|
We'll handle the ATLAS mapping, threat IDs, and risk assessment during review. If you want to include those details, great - but it's not expected.
|
||||||
|
|
||||||
> **This is for adding to the threat model, not reporting live vulnerabilities.** If you've found an exploitable vulnerability, see our [Trust page](https://trust.openclaw.ai) for responsible disclosure instructions.
|
> **This is for adding to the threat model, not reporting live vulnerabilities.** If you've found an exploitable vulnerability, see our [Trust page](https://trust.openclaw.ai) for responsible disclosure instructions.
|
||||||
|
|
||||||
### Suggest a Mitigation
|
### Suggest a Mitigation
|
||||||
|
|
||||||
Have an idea for how to address an existing threat? Open an issue or PR referencing the threat. Useful mitigations are specific and actionable - for example, "per-sender rate limiting of 10 messages/minute at the gateway" is better than "implement rate limiting."
|
Have an idea for how to address an existing threat? Open an issue or PR referencing the threat. Useful mitigations are specific and actionable - for example, "per-sender rate limiting of 10 messages/minute at the gateway" is better than "implement rate limiting."
|
||||||
|
|
||||||
### Propose an Attack Chain
|
### Propose an Attack Chain
|
||||||
|
|
||||||
@@ -29,48 +29,48 @@ Attack chains show how multiple threats combine into a realistic attack scenario
|
|||||||
|
|
||||||
### Fix or Improve Existing Content
|
### Fix or Improve Existing Content
|
||||||
|
|
||||||
Typos, clarifications, outdated info, better examples - PRs welcome, no issue needed.
|
Typos, clarifications, outdated info, better examples - PRs welcome, no issue needed.
|
||||||
|
|
||||||
## What We Use
|
## What We Use
|
||||||
|
|
||||||
### MITRE ATLAS
|
### MITRE ATLAS
|
||||||
|
|
||||||
This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/) (Adversarial Threat Landscape for AI Systems), a framework designed specifically for AI/ML threats like prompt injection, tool misuse, and agent exploitation. You don't need to know ATLAS to contribute - we map submissions to the framework during review.
|
This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/) (Adversarial Threat Landscape for AI Systems), a framework designed specifically for AI/ML threats like prompt injection, tool misuse, and agent exploitation. You don't need to know ATLAS to contribute - we map submissions to the framework during review.
|
||||||
|
|
||||||
### Threat IDs
|
### Threat IDs
|
||||||
|
|
||||||
Each threat gets an ID like `T-EXEC-003`. The categories are:
|
Each threat gets an ID like `T-EXEC-003`. The categories are:
|
||||||
|
|
||||||
| Code | Category |
|
| Code | Category |
|
||||||
|------|----------|
|
| ------- | ------------------------------------------ |
|
||||||
| RECON | Reconnaissance - information gathering |
|
| RECON | Reconnaissance - information gathering |
|
||||||
| ACCESS | Initial access - gaining entry |
|
| ACCESS | Initial access - gaining entry |
|
||||||
| EXEC | Execution - running malicious actions |
|
| EXEC | Execution - running malicious actions |
|
||||||
| PERSIST | Persistence - maintaining access |
|
| PERSIST | Persistence - maintaining access |
|
||||||
| EVADE | Defense evasion - avoiding detection |
|
| EVADE | Defense evasion - avoiding detection |
|
||||||
| DISC | Discovery - learning about the environment |
|
| DISC | Discovery - learning about the environment |
|
||||||
| EXFIL | Exfiltration - stealing data |
|
| EXFIL | Exfiltration - stealing data |
|
||||||
| IMPACT | Impact - damage or disruption |
|
| IMPACT | Impact - damage or disruption |
|
||||||
|
|
||||||
IDs are assigned by maintainers during review. You don't need to pick one.
|
IDs are assigned by maintainers during review. You don't need to pick one.
|
||||||
|
|
||||||
### Risk Levels
|
### Risk Levels
|
||||||
|
|
||||||
| Level | Meaning |
|
| Level | Meaning |
|
||||||
|-------|---------|
|
| ------------ | ----------------------------------------------------------------- |
|
||||||
| **Critical** | Full system compromise, or high likelihood + critical impact |
|
| **Critical** | Full system compromise, or high likelihood + critical impact |
|
||||||
| **High** | Significant damage likely, or medium likelihood + critical impact |
|
| **High** | Significant damage likely, or medium likelihood + critical impact |
|
||||||
| **Medium** | Moderate risk, or low likelihood + high impact |
|
| **Medium** | Moderate risk, or low likelihood + high impact |
|
||||||
| **Low** | Unlikely and limited impact |
|
| **Low** | Unlikely and limited impact |
|
||||||
|
|
||||||
If you're unsure about the risk level, just describe the impact and we'll assess it.
|
If you're unsure about the risk level, just describe the impact and we'll assess it.
|
||||||
|
|
||||||
## Review Process
|
## Review Process
|
||||||
|
|
||||||
1. **Triage** - We review new submissions within 48 hours
|
1. **Triage** - We review new submissions within 48 hours
|
||||||
2. **Assessment** - We verify feasibility, assign ATLAS mapping and threat ID, validate risk level
|
2. **Assessment** - We verify feasibility, assign ATLAS mapping and threat ID, validate risk level
|
||||||
3. **Documentation** - We ensure everything is formatted and complete
|
3. **Documentation** - We ensure everything is formatted and complete
|
||||||
4. **Merge** - Added to the threat model and visualization
|
4. **Merge** - Added to the threat model and visualization
|
||||||
|
|
||||||
## Resources
|
## Resources
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the industry-standard framework for documenting adversarial threats to AI/ML systems. ATLAS is maintained by [MITRE](https://www.mitre.org/) in collaboration with the AI security community.
|
This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the industry-standard framework for documenting adversarial threats to AI/ML systems. ATLAS is maintained by [MITRE](https://www.mitre.org/) in collaboration with the AI security community.
|
||||||
|
|
||||||
**Key ATLAS Resources:**
|
**Key ATLAS Resources:**
|
||||||
|
|
||||||
- [ATLAS Techniques](https://atlas.mitre.org/techniques/)
|
- [ATLAS Techniques](https://atlas.mitre.org/techniques/)
|
||||||
- [ATLAS Tactics](https://atlas.mitre.org/tactics/)
|
- [ATLAS Tactics](https://atlas.mitre.org/tactics/)
|
||||||
- [ATLAS Case Studies](https://atlas.mitre.org/studies/)
|
- [ATLAS Case Studies](https://atlas.mitre.org/studies/)
|
||||||
@@ -21,6 +22,7 @@ This threat model is built on [MITRE ATLAS](https://atlas.mitre.org/), the indus
|
|||||||
### Contributing to This Threat Model
|
### Contributing to This Threat Model
|
||||||
|
|
||||||
This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](./CONTRIBUTING-THREAT-MODEL.md) for guidelines on contributing:
|
This is a living document maintained by the OpenClaw community. See [CONTRIBUTING-THREAT-MODEL.md](./CONTRIBUTING-THREAT-MODEL.md) for guidelines on contributing:
|
||||||
|
|
||||||
- Reporting new threats
|
- Reporting new threats
|
||||||
- Updating existing threats
|
- Updating existing threats
|
||||||
- Proposing attack chains
|
- Proposing attack chains
|
||||||
@@ -36,14 +38,14 @@ This threat model documents adversarial threats to the OpenClaw AI agent platfor
|
|||||||
|
|
||||||
### 1.2 Scope
|
### 1.2 Scope
|
||||||
|
|
||||||
| Component | Included | Notes |
|
| Component | Included | Notes |
|
||||||
|-----------|----------|-------|
|
| ---------------------- | -------- | ------------------------------------------------ |
|
||||||
| OpenClaw Agent Runtime | Yes | Core agent execution, tool calls, sessions |
|
| OpenClaw Agent Runtime | Yes | Core agent execution, tool calls, sessions |
|
||||||
| Gateway | Yes | Authentication, routing, channel integration |
|
| Gateway | Yes | Authentication, routing, channel integration |
|
||||||
| Channel Integrations | Yes | WhatsApp, Telegram, Discord, Signal, Slack, etc. |
|
| Channel Integrations | Yes | WhatsApp, Telegram, Discord, Signal, Slack, etc. |
|
||||||
| ClawHub Marketplace | Yes | Skill publishing, moderation, distribution |
|
| ClawHub Marketplace | Yes | Skill publishing, moderation, distribution |
|
||||||
| MCP Servers | Yes | External tool providers |
|
| MCP Servers | Yes | External tool providers |
|
||||||
| User Devices | Partial | Mobile apps, desktop clients |
|
| User Devices | Partial | Mobile apps, desktop clients |
|
||||||
|
|
||||||
### 1.3 Out of Scope
|
### 1.3 Out of Scope
|
||||||
|
|
||||||
@@ -122,14 +124,14 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
### 2.2 Data Flows
|
### 2.2 Data Flows
|
||||||
|
|
||||||
| Flow | Source | Destination | Data | Protection |
|
| Flow | Source | Destination | Data | Protection |
|
||||||
|------|--------|-------------|------|------------|
|
| ---- | ------- | ----------- | ------------------ | -------------------- |
|
||||||
| F1 | Channel | Gateway | User messages | TLS, AllowFrom |
|
| F1 | Channel | Gateway | User messages | TLS, AllowFrom |
|
||||||
| F2 | Gateway | Agent | Routed messages | Session isolation |
|
| F2 | Gateway | Agent | Routed messages | Session isolation |
|
||||||
| F3 | Agent | Tools | Tool invocations | Policy enforcement |
|
| F3 | Agent | Tools | Tool invocations | Policy enforcement |
|
||||||
| F4 | Agent | External | web_fetch requests | SSRF blocking |
|
| F4 | Agent | External | web_fetch requests | SSRF blocking |
|
||||||
| F5 | ClawHub | Agent | Skill code | Moderation, scanning |
|
| F5 | ClawHub | Agent | Skill code | Moderation, scanning |
|
||||||
| F6 | Agent | Channel | Responses | Output filtering |
|
| F6 | Agent | Channel | Responses | Output filtering |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,27 +141,27 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-RECON-001: Agent Endpoint Discovery
|
#### T-RECON-001: Agent Endpoint Discovery
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | -------------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0006 - Active Scanning |
|
| **ATLAS ID** | AML.T0006 - Active Scanning |
|
||||||
| **Description** | Attacker scans for exposed OpenClaw gateway endpoints |
|
| **Description** | Attacker scans for exposed OpenClaw gateway endpoints |
|
||||||
| **Attack Vector** | Network scanning, shodan queries, DNS enumeration |
|
| **Attack Vector** | Network scanning, shodan queries, DNS enumeration |
|
||||||
| **Affected Components** | Gateway, exposed API endpoints |
|
| **Affected Components** | Gateway, exposed API endpoints |
|
||||||
| **Current Mitigations** | Tailscale auth option, bind to loopback by default |
|
| **Current Mitigations** | Tailscale auth option, bind to loopback by default |
|
||||||
| **Residual Risk** | Medium - Public gateways discoverable |
|
| **Residual Risk** | Medium - Public gateways discoverable |
|
||||||
| **Recommendations** | Document secure deployment, add rate limiting on discovery endpoints |
|
| **Recommendations** | Document secure deployment, add rate limiting on discovery endpoints |
|
||||||
|
|
||||||
#### T-RECON-002: Channel Integration Probing
|
#### T-RECON-002: Channel Integration Probing
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------------------ |
|
||||||
| **ATLAS ID** | AML.T0006 - Active Scanning |
|
| **ATLAS ID** | AML.T0006 - Active Scanning |
|
||||||
| **Description** | Attacker probes messaging channels to identify AI-managed accounts |
|
| **Description** | Attacker probes messaging channels to identify AI-managed accounts |
|
||||||
| **Attack Vector** | Sending test messages, observing response patterns |
|
| **Attack Vector** | Sending test messages, observing response patterns |
|
||||||
| **Affected Components** | All channel integrations |
|
| **Affected Components** | All channel integrations |
|
||||||
| **Current Mitigations** | None specific |
|
| **Current Mitigations** | None specific |
|
||||||
| **Residual Risk** | Low - Limited value from discovery alone |
|
| **Residual Risk** | Low - Limited value from discovery alone |
|
||||||
| **Recommendations** | Consider response timing randomization |
|
| **Recommendations** | Consider response timing randomization |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,39 +169,39 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-ACCESS-001: Pairing Code Interception
|
#### T-ACCESS-001: Pairing Code Interception
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | -------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
||||||
| **Description** | Attacker intercepts pairing code during 30s grace period |
|
| **Description** | Attacker intercepts pairing code during 30s grace period |
|
||||||
| **Attack Vector** | Shoulder surfing, network sniffing, social engineering |
|
| **Attack Vector** | Shoulder surfing, network sniffing, social engineering |
|
||||||
| **Affected Components** | Device pairing system |
|
| **Affected Components** | Device pairing system |
|
||||||
| **Current Mitigations** | 30s expiry, codes sent via existing channel |
|
| **Current Mitigations** | 30s expiry, codes sent via existing channel |
|
||||||
| **Residual Risk** | Medium - Grace period exploitable |
|
| **Residual Risk** | Medium - Grace period exploitable |
|
||||||
| **Recommendations** | Reduce grace period, add confirmation step |
|
| **Recommendations** | Reduce grace period, add confirmation step |
|
||||||
|
|
||||||
#### T-ACCESS-002: AllowFrom Spoofing
|
#### T-ACCESS-002: AllowFrom Spoofing
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||||
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
||||||
| **Description** | Attacker spoofs allowed sender identity in channel |
|
| **Description** | Attacker spoofs allowed sender identity in channel |
|
||||||
| **Attack Vector** | Depends on channel - phone number spoofing, username impersonation |
|
| **Attack Vector** | Depends on channel - phone number spoofing, username impersonation |
|
||||||
| **Affected Components** | AllowFrom validation per channel |
|
| **Affected Components** | AllowFrom validation per channel |
|
||||||
| **Current Mitigations** | Channel-specific identity verification |
|
| **Current Mitigations** | Channel-specific identity verification |
|
||||||
| **Residual Risk** | Medium - Some channels vulnerable to spoofing |
|
| **Residual Risk** | Medium - Some channels vulnerable to spoofing |
|
||||||
| **Recommendations** | Document channel-specific risks, add cryptographic verification where possible |
|
| **Recommendations** | Document channel-specific risks, add cryptographic verification where possible |
|
||||||
|
|
||||||
#### T-ACCESS-003: Token Theft
|
#### T-ACCESS-003: Token Theft
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ----------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
||||||
| **Description** | Attacker steals authentication tokens from config files |
|
| **Description** | Attacker steals authentication tokens from config files |
|
||||||
| **Attack Vector** | Malware, unauthorized device access, config backup exposure |
|
| **Attack Vector** | Malware, unauthorized device access, config backup exposure |
|
||||||
| **Affected Components** | ~/.openclaw/credentials/, config storage |
|
| **Affected Components** | ~/.openclaw/credentials/, config storage |
|
||||||
| **Current Mitigations** | File permissions |
|
| **Current Mitigations** | File permissions |
|
||||||
| **Residual Risk** | High - Tokens stored in plaintext |
|
| **Residual Risk** | High - Tokens stored in plaintext |
|
||||||
| **Recommendations** | Implement token encryption at rest, add token rotation |
|
| **Recommendations** | Implement token encryption at rest, add token rotation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -207,51 +209,51 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-EXEC-001: Direct Prompt Injection
|
#### T-EXEC-001: Direct Prompt Injection
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct |
|
| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct |
|
||||||
| **Description** | Attacker sends crafted prompts to manipulate agent behavior |
|
| **Description** | Attacker sends crafted prompts to manipulate agent behavior |
|
||||||
| **Attack Vector** | Channel messages containing adversarial instructions |
|
| **Attack Vector** | Channel messages containing adversarial instructions |
|
||||||
| **Affected Components** | Agent LLM, all input surfaces |
|
| **Affected Components** | Agent LLM, all input surfaces |
|
||||||
| **Current Mitigations** | Pattern detection, external content wrapping |
|
| **Current Mitigations** | Pattern detection, external content wrapping |
|
||||||
| **Residual Risk** | Critical - Detection only, no blocking; sophisticated attacks bypass |
|
| **Residual Risk** | Critical - Detection only, no blocking; sophisticated attacks bypass |
|
||||||
| **Recommendations** | Implement multi-layer defense, output validation, user confirmation for sensitive actions |
|
| **Recommendations** | Implement multi-layer defense, output validation, user confirmation for sensitive actions |
|
||||||
|
|
||||||
#### T-EXEC-002: Indirect Prompt Injection
|
#### T-EXEC-002: Indirect Prompt Injection
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ----------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0051.001 - LLM Prompt Injection: Indirect |
|
| **ATLAS ID** | AML.T0051.001 - LLM Prompt Injection: Indirect |
|
||||||
| **Description** | Attacker embeds malicious instructions in fetched content |
|
| **Description** | Attacker embeds malicious instructions in fetched content |
|
||||||
| **Attack Vector** | Malicious URLs, poisoned emails, compromised webhooks |
|
| **Attack Vector** | Malicious URLs, poisoned emails, compromised webhooks |
|
||||||
| **Affected Components** | web_fetch, email ingestion, external data sources |
|
| **Affected Components** | web_fetch, email ingestion, external data sources |
|
||||||
| **Current Mitigations** | Content wrapping with XML tags and security notice |
|
| **Current Mitigations** | Content wrapping with XML tags and security notice |
|
||||||
| **Residual Risk** | High - LLM may ignore wrapper instructions |
|
| **Residual Risk** | High - LLM may ignore wrapper instructions |
|
||||||
| **Recommendations** | Implement content sanitization, separate execution contexts |
|
| **Recommendations** | Implement content sanitization, separate execution contexts |
|
||||||
|
|
||||||
#### T-EXEC-003: Tool Argument Injection
|
#### T-EXEC-003: Tool Argument Injection
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------------ |
|
||||||
| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct |
|
| **ATLAS ID** | AML.T0051.000 - LLM Prompt Injection: Direct |
|
||||||
| **Description** | Attacker manipulates tool arguments through prompt injection |
|
| **Description** | Attacker manipulates tool arguments through prompt injection |
|
||||||
| **Attack Vector** | Crafted prompts that influence tool parameter values |
|
| **Attack Vector** | Crafted prompts that influence tool parameter values |
|
||||||
| **Affected Components** | All tool invocations |
|
| **Affected Components** | All tool invocations |
|
||||||
| **Current Mitigations** | Exec approvals for dangerous commands |
|
| **Current Mitigations** | Exec approvals for dangerous commands |
|
||||||
| **Residual Risk** | High - Relies on user judgment |
|
| **Residual Risk** | High - Relies on user judgment |
|
||||||
| **Recommendations** | Implement argument validation, parameterized tool calls |
|
| **Recommendations** | Implement argument validation, parameterized tool calls |
|
||||||
|
|
||||||
#### T-EXEC-004: Exec Approval Bypass
|
#### T-EXEC-004: Exec Approval Bypass
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ---------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
||||||
| **Description** | Attacker crafts commands that bypass approval allowlist |
|
| **Description** | Attacker crafts commands that bypass approval allowlist |
|
||||||
| **Attack Vector** | Command obfuscation, alias exploitation, path manipulation |
|
| **Attack Vector** | Command obfuscation, alias exploitation, path manipulation |
|
||||||
| **Affected Components** | exec-approvals.ts, command allowlist |
|
| **Affected Components** | exec-approvals.ts, command allowlist |
|
||||||
| **Current Mitigations** | Allowlist + ask mode |
|
| **Current Mitigations** | Allowlist + ask mode |
|
||||||
| **Residual Risk** | High - No command sanitization |
|
| **Residual Risk** | High - No command sanitization |
|
||||||
| **Recommendations** | Implement command normalization, expand blocklist |
|
| **Recommendations** | Implement command normalization, expand blocklist |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -259,39 +261,39 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-PERSIST-001: Malicious Skill Installation
|
#### T-PERSIST-001: Malicious Skill Installation
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------------------------ |
|
||||||
| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software |
|
| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software |
|
||||||
| **Description** | Attacker publishes malicious skill to ClawHub |
|
| **Description** | Attacker publishes malicious skill to ClawHub |
|
||||||
| **Attack Vector** | Create account, publish skill with hidden malicious code |
|
| **Attack Vector** | Create account, publish skill with hidden malicious code |
|
||||||
| **Affected Components** | ClawHub, skill loading, agent execution |
|
| **Affected Components** | ClawHub, skill loading, agent execution |
|
||||||
| **Current Mitigations** | GitHub account age verification, pattern-based moderation flags |
|
| **Current Mitigations** | GitHub account age verification, pattern-based moderation flags |
|
||||||
| **Residual Risk** | Critical - No sandboxing, limited review |
|
| **Residual Risk** | Critical - No sandboxing, limited review |
|
||||||
| **Recommendations** | VirusTotal integration (in progress), skill sandboxing, community review |
|
| **Recommendations** | VirusTotal integration (in progress), skill sandboxing, community review |
|
||||||
|
|
||||||
#### T-PERSIST-002: Skill Update Poisoning
|
#### T-PERSIST-002: Skill Update Poisoning
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | -------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software |
|
| **ATLAS ID** | AML.T0010.001 - Supply Chain Compromise: AI Software |
|
||||||
| **Description** | Attacker compromises popular skill and pushes malicious update |
|
| **Description** | Attacker compromises popular skill and pushes malicious update |
|
||||||
| **Attack Vector** | Account compromise, social engineering of skill owner |
|
| **Attack Vector** | Account compromise, social engineering of skill owner |
|
||||||
| **Affected Components** | ClawHub versioning, auto-update flows |
|
| **Affected Components** | ClawHub versioning, auto-update flows |
|
||||||
| **Current Mitigations** | Version fingerprinting |
|
| **Current Mitigations** | Version fingerprinting |
|
||||||
| **Residual Risk** | High - Auto-updates may pull malicious versions |
|
| **Residual Risk** | High - Auto-updates may pull malicious versions |
|
||||||
| **Recommendations** | Implement update signing, rollback capability, version pinning |
|
| **Recommendations** | Implement update signing, rollback capability, version pinning |
|
||||||
|
|
||||||
#### T-PERSIST-003: Agent Configuration Tampering
|
#### T-PERSIST-003: Agent Configuration Tampering
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | --------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0010.002 - Supply Chain Compromise: Data |
|
| **ATLAS ID** | AML.T0010.002 - Supply Chain Compromise: Data |
|
||||||
| **Description** | Attacker modifies agent configuration to persist access |
|
| **Description** | Attacker modifies agent configuration to persist access |
|
||||||
| **Attack Vector** | Config file modification, settings injection |
|
| **Attack Vector** | Config file modification, settings injection |
|
||||||
| **Affected Components** | Agent config, tool policies |
|
| **Affected Components** | Agent config, tool policies |
|
||||||
| **Current Mitigations** | File permissions |
|
| **Current Mitigations** | File permissions |
|
||||||
| **Residual Risk** | Medium - Requires local access |
|
| **Residual Risk** | Medium - Requires local access |
|
||||||
| **Recommendations** | Config integrity verification, audit logging for config changes |
|
| **Recommendations** | Config integrity verification, audit logging for config changes |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -299,27 +301,27 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-EVADE-001: Moderation Pattern Bypass
|
#### T-EVADE-001: Moderation Pattern Bypass
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
||||||
| **Description** | Attacker crafts skill content to evade moderation patterns |
|
| **Description** | Attacker crafts skill content to evade moderation patterns |
|
||||||
| **Attack Vector** | Unicode homoglyphs, encoding tricks, dynamic loading |
|
| **Attack Vector** | Unicode homoglyphs, encoding tricks, dynamic loading |
|
||||||
| **Affected Components** | ClawHub moderation.ts |
|
| **Affected Components** | ClawHub moderation.ts |
|
||||||
| **Current Mitigations** | Pattern-based FLAG_RULES |
|
| **Current Mitigations** | Pattern-based FLAG_RULES |
|
||||||
| **Residual Risk** | High - Simple regex easily bypassed |
|
| **Residual Risk** | High - Simple regex easily bypassed |
|
||||||
| **Recommendations** | Add behavioral analysis (VirusTotal Code Insight), AST-based detection |
|
| **Recommendations** | Add behavioral analysis (VirusTotal Code Insight), AST-based detection |
|
||||||
|
|
||||||
#### T-EVADE-002: Content Wrapper Escape
|
#### T-EVADE-002: Content Wrapper Escape
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
| **ATLAS ID** | AML.T0043 - Craft Adversarial Data |
|
||||||
| **Description** | Attacker crafts content that escapes XML wrapper context |
|
| **Description** | Attacker crafts content that escapes XML wrapper context |
|
||||||
| **Attack Vector** | Tag manipulation, context confusion, instruction override |
|
| **Attack Vector** | Tag manipulation, context confusion, instruction override |
|
||||||
| **Affected Components** | External content wrapping |
|
| **Affected Components** | External content wrapping |
|
||||||
| **Current Mitigations** | XML tags + security notice |
|
| **Current Mitigations** | XML tags + security notice |
|
||||||
| **Residual Risk** | Medium - Novel escapes discovered regularly |
|
| **Residual Risk** | Medium - Novel escapes discovered regularly |
|
||||||
| **Recommendations** | Multiple wrapper layers, output-side validation |
|
| **Recommendations** | Multiple wrapper layers, output-side validation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,27 +329,27 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-DISC-001: Tool Enumeration
|
#### T-DISC-001: Tool Enumeration
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ----------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
||||||
| **Description** | Attacker enumerates available tools through prompting |
|
| **Description** | Attacker enumerates available tools through prompting |
|
||||||
| **Attack Vector** | "What tools do you have?" style queries |
|
| **Attack Vector** | "What tools do you have?" style queries |
|
||||||
| **Affected Components** | Agent tool registry |
|
| **Affected Components** | Agent tool registry |
|
||||||
| **Current Mitigations** | None specific |
|
| **Current Mitigations** | None specific |
|
||||||
| **Residual Risk** | Low - Tools generally documented |
|
| **Residual Risk** | Low - Tools generally documented |
|
||||||
| **Recommendations** | Consider tool visibility controls |
|
| **Recommendations** | Consider tool visibility controls |
|
||||||
|
|
||||||
#### T-DISC-002: Session Data Extraction
|
#### T-DISC-002: Session Data Extraction
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ----------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
| **ATLAS ID** | AML.T0040 - AI Model Inference API Access |
|
||||||
| **Description** | Attacker extracts sensitive data from session context |
|
| **Description** | Attacker extracts sensitive data from session context |
|
||||||
| **Attack Vector** | "What did we discuss?" queries, context probing |
|
| **Attack Vector** | "What did we discuss?" queries, context probing |
|
||||||
| **Affected Components** | Session transcripts, context window |
|
| **Affected Components** | Session transcripts, context window |
|
||||||
| **Current Mitigations** | Session isolation per sender |
|
| **Current Mitigations** | Session isolation per sender |
|
||||||
| **Residual Risk** | Medium - Within-session data accessible |
|
| **Residual Risk** | Medium - Within-session data accessible |
|
||||||
| **Recommendations** | Implement sensitive data redaction in context |
|
| **Recommendations** | Implement sensitive data redaction in context |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -355,39 +357,39 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-EXFIL-001: Data Theft via web_fetch
|
#### T-EXFIL-001: Data Theft via web_fetch
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0009 - Collection |
|
| **ATLAS ID** | AML.T0009 - Collection |
|
||||||
| **Description** | Attacker exfiltrates data by instructing agent to send to external URL |
|
| **Description** | Attacker exfiltrates data by instructing agent to send to external URL |
|
||||||
| **Attack Vector** | Prompt injection causing agent to POST data to attacker server |
|
| **Attack Vector** | Prompt injection causing agent to POST data to attacker server |
|
||||||
| **Affected Components** | web_fetch tool |
|
| **Affected Components** | web_fetch tool |
|
||||||
| **Current Mitigations** | SSRF blocking for internal networks |
|
| **Current Mitigations** | SSRF blocking for internal networks |
|
||||||
| **Residual Risk** | High - External URLs permitted |
|
| **Residual Risk** | High - External URLs permitted |
|
||||||
| **Recommendations** | Implement URL allowlisting, data classification awareness |
|
| **Recommendations** | Implement URL allowlisting, data classification awareness |
|
||||||
|
|
||||||
#### T-EXFIL-002: Unauthorized Message Sending
|
#### T-EXFIL-002: Unauthorized Message Sending
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ---------------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0009 - Collection |
|
| **ATLAS ID** | AML.T0009 - Collection |
|
||||||
| **Description** | Attacker causes agent to send messages containing sensitive data |
|
| **Description** | Attacker causes agent to send messages containing sensitive data |
|
||||||
| **Attack Vector** | Prompt injection causing agent to message attacker |
|
| **Attack Vector** | Prompt injection causing agent to message attacker |
|
||||||
| **Affected Components** | Message tool, channel integrations |
|
| **Affected Components** | Message tool, channel integrations |
|
||||||
| **Current Mitigations** | Outbound messaging gating |
|
| **Current Mitigations** | Outbound messaging gating |
|
||||||
| **Residual Risk** | Medium - Gating may be bypassed |
|
| **Residual Risk** | Medium - Gating may be bypassed |
|
||||||
| **Recommendations** | Require explicit confirmation for new recipients |
|
| **Recommendations** | Require explicit confirmation for new recipients |
|
||||||
|
|
||||||
#### T-EXFIL-003: Credential Harvesting
|
#### T-EXFIL-003: Credential Harvesting
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0009 - Collection |
|
| **ATLAS ID** | AML.T0009 - Collection |
|
||||||
| **Description** | Malicious skill harvests credentials from agent context |
|
| **Description** | Malicious skill harvests credentials from agent context |
|
||||||
| **Attack Vector** | Skill code reads environment variables, config files |
|
| **Attack Vector** | Skill code reads environment variables, config files |
|
||||||
| **Affected Components** | Skill execution environment |
|
| **Affected Components** | Skill execution environment |
|
||||||
| **Current Mitigations** | None specific to skills |
|
| **Current Mitigations** | None specific to skills |
|
||||||
| **Residual Risk** | Critical - Skills run with agent privileges |
|
| **Residual Risk** | Critical - Skills run with agent privileges |
|
||||||
| **Recommendations** | Skill sandboxing, credential isolation |
|
| **Recommendations** | Skill sandboxing, credential isolation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,39 +397,39 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
#### T-IMPACT-001: Unauthorized Command Execution
|
#### T-IMPACT-001: Unauthorized Command Execution
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | --------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
||||||
| **Description** | Attacker executes arbitrary commands on user system |
|
| **Description** | Attacker executes arbitrary commands on user system |
|
||||||
| **Attack Vector** | Prompt injection combined with exec approval bypass |
|
| **Attack Vector** | Prompt injection combined with exec approval bypass |
|
||||||
| **Affected Components** | Bash tool, command execution |
|
| **Affected Components** | Bash tool, command execution |
|
||||||
| **Current Mitigations** | Exec approvals, Docker sandbox option |
|
| **Current Mitigations** | Exec approvals, Docker sandbox option |
|
||||||
| **Residual Risk** | Critical - Host execution without sandbox |
|
| **Residual Risk** | Critical - Host execution without sandbox |
|
||||||
| **Recommendations** | Default to sandbox, improve approval UX |
|
| **Recommendations** | Default to sandbox, improve approval UX |
|
||||||
|
|
||||||
#### T-IMPACT-002: Resource Exhaustion (DoS)
|
#### T-IMPACT-002: Resource Exhaustion (DoS)
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | -------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
||||||
| **Description** | Attacker exhausts API credits or compute resources |
|
| **Description** | Attacker exhausts API credits or compute resources |
|
||||||
| **Attack Vector** | Automated message flooding, expensive tool calls |
|
| **Attack Vector** | Automated message flooding, expensive tool calls |
|
||||||
| **Affected Components** | Gateway, agent sessions, API provider |
|
| **Affected Components** | Gateway, agent sessions, API provider |
|
||||||
| **Current Mitigations** | None |
|
| **Current Mitigations** | None |
|
||||||
| **Residual Risk** | High - No rate limiting |
|
| **Residual Risk** | High - No rate limiting |
|
||||||
| **Recommendations** | Implement per-sender rate limits, cost budgets |
|
| **Recommendations** | Implement per-sender rate limits, cost budgets |
|
||||||
|
|
||||||
#### T-IMPACT-003: Reputation Damage
|
#### T-IMPACT-003: Reputation Damage
|
||||||
|
|
||||||
| Attribute | Value |
|
| Attribute | Value |
|
||||||
|-----------|-------|
|
| ----------------------- | ------------------------------------------------------- |
|
||||||
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
| **ATLAS ID** | AML.T0031 - Erode AI Model Integrity |
|
||||||
| **Description** | Attacker causes agent to send harmful/offensive content |
|
| **Description** | Attacker causes agent to send harmful/offensive content |
|
||||||
| **Attack Vector** | Prompt injection causing inappropriate responses |
|
| **Attack Vector** | Prompt injection causing inappropriate responses |
|
||||||
| **Affected Components** | Output generation, channel messaging |
|
| **Affected Components** | Output generation, channel messaging |
|
||||||
| **Current Mitigations** | LLM provider content policies |
|
| **Current Mitigations** | LLM provider content policies |
|
||||||
| **Residual Risk** | Medium - Provider filters imperfect |
|
| **Residual Risk** | Medium - Provider filters imperfect |
|
||||||
| **Recommendations** | Output filtering layer, user controls |
|
| **Recommendations** | Output filtering layer, user controls |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -435,15 +437,15 @@ Nothing is explicitly out of scope for this threat model.
|
|||||||
|
|
||||||
### 4.1 Current Security Controls
|
### 4.1 Current Security Controls
|
||||||
|
|
||||||
| Control | Implementation | Effectiveness |
|
| Control | Implementation | Effectiveness |
|
||||||
|---------|----------------|---------------|
|
| -------------------- | --------------------------- | ---------------------------------------------------- |
|
||||||
| GitHub Account Age | `requireGitHubAccountAge()` | Medium - Raises bar for new attackers |
|
| GitHub Account Age | `requireGitHubAccountAge()` | Medium - Raises bar for new attackers |
|
||||||
| Path Sanitization | `sanitizePath()` | High - Prevents path traversal |
|
| Path Sanitization | `sanitizePath()` | High - Prevents path traversal |
|
||||||
| File Type Validation | `isTextFile()` | Medium - Only text files, but can still be malicious |
|
| File Type Validation | `isTextFile()` | Medium - Only text files, but can still be malicious |
|
||||||
| Size Limits | 50MB total bundle | High - Prevents resource exhaustion |
|
| Size Limits | 50MB total bundle | High - Prevents resource exhaustion |
|
||||||
| Required SKILL.md | Mandatory readme | Low security value - Informational only |
|
| Required SKILL.md | Mandatory readme | Low security value - Informational only |
|
||||||
| Pattern Moderation | FLAG_RULES in moderation.ts | Low - Easily bypassed |
|
| Pattern Moderation | FLAG_RULES in moderation.ts | Low - Easily bypassed |
|
||||||
| Moderation Status | `moderationStatus` field | Medium - Manual review possible |
|
| Moderation Status | `moderationStatus` field | Medium - Manual review possible |
|
||||||
|
|
||||||
### 4.2 Moderation Flag Patterns
|
### 4.2 Moderation Flag Patterns
|
||||||
|
|
||||||
@@ -463,6 +465,7 @@ Current patterns in `moderation.ts`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Limitations:**
|
**Limitations:**
|
||||||
|
|
||||||
- Only checks slug, displayName, summary, frontmatter, metadata, file paths
|
- Only checks slug, displayName, summary, frontmatter, metadata, file paths
|
||||||
- Does not analyze actual skill code content
|
- Does not analyze actual skill code content
|
||||||
- Simple regex easily bypassed with obfuscation
|
- Simple regex easily bypassed with obfuscation
|
||||||
@@ -470,12 +473,12 @@ Current patterns in `moderation.ts`:
|
|||||||
|
|
||||||
### 4.3 Planned Improvements
|
### 4.3 Planned Improvements
|
||||||
|
|
||||||
| Improvement | Status | Impact |
|
| Improvement | Status | Impact |
|
||||||
|-------------|--------|--------|
|
| ---------------------- | ------------------------------------- | --------------------------------------------------------------------- |
|
||||||
| VirusTotal Integration | In Progress | High - Code Insight behavioral analysis |
|
| VirusTotal Integration | In Progress | High - Code Insight behavioral analysis |
|
||||||
| Community Reporting | Partial (`skillReports` table exists) | Medium |
|
| Community Reporting | Partial (`skillReports` table exists) | Medium |
|
||||||
| Audit Logging | Partial (`auditLogs` table exists) | Medium |
|
| Audit Logging | Partial (`auditLogs` table exists) | Medium |
|
||||||
| Badge System | Implemented | Medium - `highlighted`, `official`, `deprecated`, `redactionApproved` |
|
| Badge System | Implemented | Medium - `highlighted`, `official`, `deprecated`, `redactionApproved` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -483,37 +486,40 @@ Current patterns in `moderation.ts`:
|
|||||||
|
|
||||||
### 5.1 Likelihood vs Impact
|
### 5.1 Likelihood vs Impact
|
||||||
|
|
||||||
| Threat ID | Likelihood | Impact | Risk Level | Priority |
|
| Threat ID | Likelihood | Impact | Risk Level | Priority |
|
||||||
|-----------|------------|--------|------------|----------|
|
| ------------- | ---------- | -------- | ------------ | -------- |
|
||||||
| T-EXEC-001 | High | Critical | **Critical** | P0 |
|
| T-EXEC-001 | High | Critical | **Critical** | P0 |
|
||||||
| T-PERSIST-001 | High | Critical | **Critical** | P0 |
|
| T-PERSIST-001 | High | Critical | **Critical** | P0 |
|
||||||
| T-EXFIL-003 | Medium | Critical | **Critical** | P0 |
|
| T-EXFIL-003 | Medium | Critical | **Critical** | P0 |
|
||||||
| T-IMPACT-001 | Medium | Critical | **High** | P1 |
|
| T-IMPACT-001 | Medium | Critical | **High** | P1 |
|
||||||
| T-EXEC-002 | High | High | **High** | P1 |
|
| T-EXEC-002 | High | High | **High** | P1 |
|
||||||
| T-EXEC-004 | Medium | High | **High** | P1 |
|
| T-EXEC-004 | Medium | High | **High** | P1 |
|
||||||
| T-ACCESS-003 | Medium | High | **High** | P1 |
|
| T-ACCESS-003 | Medium | High | **High** | P1 |
|
||||||
| T-EXFIL-001 | Medium | High | **High** | P1 |
|
| T-EXFIL-001 | Medium | High | **High** | P1 |
|
||||||
| T-IMPACT-002 | High | Medium | **High** | P1 |
|
| T-IMPACT-002 | High | Medium | **High** | P1 |
|
||||||
| T-EVADE-001 | High | Medium | **Medium** | P2 |
|
| T-EVADE-001 | High | Medium | **Medium** | P2 |
|
||||||
| T-ACCESS-001 | Low | High | **Medium** | P2 |
|
| T-ACCESS-001 | Low | High | **Medium** | P2 |
|
||||||
| T-ACCESS-002 | Low | High | **Medium** | P2 |
|
| T-ACCESS-002 | Low | High | **Medium** | P2 |
|
||||||
| T-PERSIST-002 | Low | High | **Medium** | P2 |
|
| T-PERSIST-002 | Low | High | **Medium** | P2 |
|
||||||
|
|
||||||
### 5.2 Critical Path Attack Chains
|
### 5.2 Critical Path Attack Chains
|
||||||
|
|
||||||
**Attack Chain 1: Skill-Based Data Theft**
|
**Attack Chain 1: Skill-Based Data Theft**
|
||||||
|
|
||||||
```
|
```
|
||||||
T-PERSIST-001 → T-EVADE-001 → T-EXFIL-003
|
T-PERSIST-001 → T-EVADE-001 → T-EXFIL-003
|
||||||
(Publish malicious skill) → (Evade moderation) → (Harvest credentials)
|
(Publish malicious skill) → (Evade moderation) → (Harvest credentials)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Attack Chain 2: Prompt Injection to RCE**
|
**Attack Chain 2: Prompt Injection to RCE**
|
||||||
|
|
||||||
```
|
```
|
||||||
T-EXEC-001 → T-EXEC-004 → T-IMPACT-001
|
T-EXEC-001 → T-EXEC-004 → T-IMPACT-001
|
||||||
(Inject prompt) → (Bypass exec approval) → (Execute commands)
|
(Inject prompt) → (Bypass exec approval) → (Execute commands)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Attack Chain 3: Indirect Injection via Fetched Content**
|
**Attack Chain 3: Indirect Injection via Fetched Content**
|
||||||
|
|
||||||
```
|
```
|
||||||
T-EXEC-002 → T-EXFIL-001 → External exfiltration
|
T-EXEC-002 → T-EXFIL-001 → External exfiltration
|
||||||
(Poison URL content) → (Agent fetches & follows instructions) → (Data sent to attacker)
|
(Poison URL content) → (Agent fetches & follows instructions) → (Data sent to attacker)
|
||||||
@@ -525,28 +531,28 @@ T-EXEC-002 → T-EXFIL-001 → External exfiltration
|
|||||||
|
|
||||||
### 6.1 Immediate (P0)
|
### 6.1 Immediate (P0)
|
||||||
|
|
||||||
| ID | Recommendation | Addresses |
|
| ID | Recommendation | Addresses |
|
||||||
|----|----------------|-----------|
|
| ----- | ------------------------------------------- | -------------------------- |
|
||||||
| R-001 | Complete VirusTotal integration | T-PERSIST-001, T-EVADE-001 |
|
| R-001 | Complete VirusTotal integration | T-PERSIST-001, T-EVADE-001 |
|
||||||
| R-002 | Implement skill sandboxing | T-PERSIST-001, T-EXFIL-003 |
|
| R-002 | Implement skill sandboxing | T-PERSIST-001, T-EXFIL-003 |
|
||||||
| R-003 | Add output validation for sensitive actions | T-EXEC-001, T-EXEC-002 |
|
| R-003 | Add output validation for sensitive actions | T-EXEC-001, T-EXEC-002 |
|
||||||
|
|
||||||
### 6.2 Short-term (P1)
|
### 6.2 Short-term (P1)
|
||||||
|
|
||||||
| ID | Recommendation | Addresses |
|
| ID | Recommendation | Addresses |
|
||||||
|----|----------------|-----------|
|
| ----- | ---------------------------------------- | ------------ |
|
||||||
| R-004 | Implement rate limiting | T-IMPACT-002 |
|
| R-004 | Implement rate limiting | T-IMPACT-002 |
|
||||||
| R-005 | Add token encryption at rest | T-ACCESS-003 |
|
| R-005 | Add token encryption at rest | T-ACCESS-003 |
|
||||||
| R-006 | Improve exec approval UX and validation | T-EXEC-004 |
|
| R-006 | Improve exec approval UX and validation | T-EXEC-004 |
|
||||||
| R-007 | Implement URL allowlisting for web_fetch | T-EXFIL-001 |
|
| R-007 | Implement URL allowlisting for web_fetch | T-EXFIL-001 |
|
||||||
|
|
||||||
### 6.3 Medium-term (P2)
|
### 6.3 Medium-term (P2)
|
||||||
|
|
||||||
| ID | Recommendation | Addresses |
|
| ID | Recommendation | Addresses |
|
||||||
|----|----------------|-----------|
|
| ----- | ----------------------------------------------------- | ------------- |
|
||||||
| R-008 | Add cryptographic channel verification where possible | T-ACCESS-002 |
|
| R-008 | Add cryptographic channel verification where possible | T-ACCESS-002 |
|
||||||
| R-009 | Implement config integrity verification | T-PERSIST-003 |
|
| R-009 | Implement config integrity verification | T-PERSIST-003 |
|
||||||
| R-010 | Add update signing and version pinning | T-PERSIST-002 |
|
| R-010 | Add update signing and version pinning | T-PERSIST-002 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -554,44 +560,44 @@ T-EXEC-002 → T-EXFIL-001 → External exfiltration
|
|||||||
|
|
||||||
### 7.1 ATLAS Technique Mapping
|
### 7.1 ATLAS Technique Mapping
|
||||||
|
|
||||||
| ATLAS ID | Technique Name | OpenClaw Threats |
|
| ATLAS ID | Technique Name | OpenClaw Threats |
|
||||||
|----------|----------------|------------------|
|
| ------------- | ------------------------------ | ---------------------------------------------------------------- |
|
||||||
| AML.T0006 | Active Scanning | T-RECON-001, T-RECON-002 |
|
| AML.T0006 | Active Scanning | T-RECON-001, T-RECON-002 |
|
||||||
| AML.T0009 | Collection | T-EXFIL-001, T-EXFIL-002, T-EXFIL-003 |
|
| AML.T0009 | Collection | T-EXFIL-001, T-EXFIL-002, T-EXFIL-003 |
|
||||||
| AML.T0010.001 | Supply Chain: AI Software | T-PERSIST-001, T-PERSIST-002 |
|
| AML.T0010.001 | Supply Chain: AI Software | T-PERSIST-001, T-PERSIST-002 |
|
||||||
| AML.T0010.002 | Supply Chain: Data | T-PERSIST-003 |
|
| AML.T0010.002 | Supply Chain: Data | T-PERSIST-003 |
|
||||||
| AML.T0031 | Erode AI Model Integrity | T-IMPACT-001, T-IMPACT-002, T-IMPACT-003 |
|
| AML.T0031 | Erode AI Model Integrity | T-IMPACT-001, T-IMPACT-002, T-IMPACT-003 |
|
||||||
| AML.T0040 | AI Model Inference API Access | T-ACCESS-001, T-ACCESS-002, T-ACCESS-003, T-DISC-001, T-DISC-002 |
|
| AML.T0040 | AI Model Inference API Access | T-ACCESS-001, T-ACCESS-002, T-ACCESS-003, T-DISC-001, T-DISC-002 |
|
||||||
| AML.T0043 | Craft Adversarial Data | T-EXEC-004, T-EVADE-001, T-EVADE-002 |
|
| AML.T0043 | Craft Adversarial Data | T-EXEC-004, T-EVADE-001, T-EVADE-002 |
|
||||||
| AML.T0051.000 | LLM Prompt Injection: Direct | T-EXEC-001, T-EXEC-003 |
|
| AML.T0051.000 | LLM Prompt Injection: Direct | T-EXEC-001, T-EXEC-003 |
|
||||||
| AML.T0051.001 | LLM Prompt Injection: Indirect | T-EXEC-002 |
|
| AML.T0051.001 | LLM Prompt Injection: Indirect | T-EXEC-002 |
|
||||||
|
|
||||||
### 7.2 Key Security Files
|
### 7.2 Key Security Files
|
||||||
|
|
||||||
| Path | Purpose | Risk Level |
|
| Path | Purpose | Risk Level |
|
||||||
|------|---------|------------|
|
| ----------------------------------- | --------------------------- | ------------ |
|
||||||
| `src/infra/exec-approvals.ts` | Command approval logic | **Critical** |
|
| `src/infra/exec-approvals.ts` | Command approval logic | **Critical** |
|
||||||
| `src/gateway/auth.ts` | Gateway authentication | **Critical** |
|
| `src/gateway/auth.ts` | Gateway authentication | **Critical** |
|
||||||
| `src/web/inbound/access-control.ts` | Channel access control | **Critical** |
|
| `src/web/inbound/access-control.ts` | Channel access control | **Critical** |
|
||||||
| `src/infra/net/ssrf.ts` | SSRF protection | **Critical** |
|
| `src/infra/net/ssrf.ts` | SSRF protection | **Critical** |
|
||||||
| `src/security/external-content.ts` | Prompt injection mitigation | **Critical** |
|
| `src/security/external-content.ts` | Prompt injection mitigation | **Critical** |
|
||||||
| `src/agents/sandbox/tool-policy.ts` | Tool policy enforcement | **Critical** |
|
| `src/agents/sandbox/tool-policy.ts` | Tool policy enforcement | **Critical** |
|
||||||
| `convex/lib/moderation.ts` | ClawHub moderation | **High** |
|
| `convex/lib/moderation.ts` | ClawHub moderation | **High** |
|
||||||
| `convex/lib/skillPublish.ts` | Skill publishing flow | **High** |
|
| `convex/lib/skillPublish.ts` | Skill publishing flow | **High** |
|
||||||
| `src/routing/resolve-route.ts` | Session isolation | **Medium** |
|
| `src/routing/resolve-route.ts` | Session isolation | **Medium** |
|
||||||
|
|
||||||
### 7.3 Glossary
|
### 7.3 Glossary
|
||||||
|
|
||||||
| Term | Definition |
|
| Term | Definition |
|
||||||
|------|------------|
|
| -------------------- | --------------------------------------------------------- |
|
||||||
| **ATLAS** | MITRE's Adversarial Threat Landscape for AI Systems |
|
| **ATLAS** | MITRE's Adversarial Threat Landscape for AI Systems |
|
||||||
| **ClawHub** | OpenClaw's skill marketplace |
|
| **ClawHub** | OpenClaw's skill marketplace |
|
||||||
| **Gateway** | OpenClaw's message routing and authentication layer |
|
| **Gateway** | OpenClaw's message routing and authentication layer |
|
||||||
| **MCP** | Model Context Protocol - tool provider interface |
|
| **MCP** | Model Context Protocol - tool provider interface |
|
||||||
| **Prompt Injection** | Attack where malicious instructions are embedded in input |
|
| **Prompt Injection** | Attack where malicious instructions are embedded in input |
|
||||||
| **Skill** | Downloadable extension for OpenClaw agents |
|
| **Skill** | Downloadable extension for OpenClaw agents |
|
||||||
| **SSRF** | Server-Side Request Forgery |
|
| **SSRF** | Server-Side Request Forgery |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This threat model is a living document. Report security issues to security@openclaw.ai*
|
_This threat model is a living document. Report security issues to security@openclaw.ai_
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g')
|
FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g')
|
||||||
[ -z "$FILES" ] && exit 0
|
[ -z "$FILES" ] && exit 0
|
||||||
echo "$FILES" | xargs pnpm format:fix --no-error-on-unmatched-pattern
|
|
||||||
|
# Lint and format staged files
|
||||||
|
echo "$FILES" | xargs pnpm exec oxlint --fix 2>/dev/null || true
|
||||||
|
echo "$FILES" | xargs pnpm exec oxfmt --write 2>/dev/null || true
|
||||||
echo "$FILES" | xargs git add
|
echo "$FILES" | xargs git add
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
312
scripts/analyze_code_files.py
Normal file
312
scripts/analyze_code_files.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Lists the longest and shortest code files in the project.
|
||||||
|
Threshold can be set to warn about files longer or shorter than a certain number of lines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple, Dict, Set
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# File extensions to consider as code files
|
||||||
|
CODE_EXTENSIONS = {
|
||||||
|
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', # TypeScript/JavaScript
|
||||||
|
'.swift', # macOS/iOS
|
||||||
|
'.kt', '.java', # Android
|
||||||
|
'.py', '.sh', # Scripts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Directories to skip
|
||||||
|
SKIP_DIRS = {
|
||||||
|
'node_modules', '.git', 'dist', 'build', 'coverage',
|
||||||
|
'__pycache__', '.turbo', 'out', '.worktrees', 'vendor',
|
||||||
|
'Pods', 'DerivedData', '.gradle', '.idea'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filename patterns to skip in short-file warnings (barrel exports, stubs)
|
||||||
|
SKIP_SHORT_PATTERNS = {
|
||||||
|
'index.js', 'index.ts', 'postinstall.js',
|
||||||
|
}
|
||||||
|
SKIP_SHORT_SUFFIXES = ('-cli.ts',)
|
||||||
|
|
||||||
|
# Function names to skip in duplicate detection (common utilities, test helpers)
|
||||||
|
SKIP_DUPLICATE_FUNCTIONS = {
|
||||||
|
# Common utility names
|
||||||
|
'main', 'init', 'setup', 'teardown', 'cleanup', 'dispose', 'destroy',
|
||||||
|
'open', 'close', 'connect', 'disconnect', 'execute', 'run', 'start', 'stop',
|
||||||
|
'render', 'update', 'refresh', 'reset', 'clear', 'flush',
|
||||||
|
}
|
||||||
|
|
||||||
|
SKIP_DUPLICATE_PREFIXES = (
|
||||||
|
# Transformers
|
||||||
|
'normalize', 'parse', 'validate', 'serialize', 'deserialize',
|
||||||
|
'convert', 'transform', 'extract', 'encode', 'decode',
|
||||||
|
# Predicates
|
||||||
|
'is', 'has', 'can', 'should', 'will',
|
||||||
|
# Constructors/factories
|
||||||
|
'create', 'make', 'build', 'generate', 'new',
|
||||||
|
# Accessors
|
||||||
|
'get', 'set', 'read', 'write', 'load', 'save', 'fetch',
|
||||||
|
# Handlers
|
||||||
|
'handle', 'on', 'emit',
|
||||||
|
# Modifiers
|
||||||
|
'add', 'remove', 'delete', 'update', 'insert', 'append',
|
||||||
|
# Other common
|
||||||
|
'to', 'from', 'with', 'apply', 'process', 'resolve', 'ensure', 'check',
|
||||||
|
'filter', 'map', 'reduce', 'merge', 'split', 'join', 'find', 'search',
|
||||||
|
'register', 'unregister', 'subscribe', 'unsubscribe',
|
||||||
|
)
|
||||||
|
SKIP_DUPLICATE_FILE_PATTERNS = ('.test.ts', '.test.tsx', '.spec.ts')
|
||||||
|
|
||||||
|
# Known packages in the monorepo
|
||||||
|
PACKAGES = {
|
||||||
|
'src', 'apps', 'extensions', 'packages', 'scripts', 'ui', 'test', 'docs'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_package(file_path: Path, root_dir: Path) -> str:
|
||||||
|
"""Get the package name for a file, or 'root' if at top level."""
|
||||||
|
try:
|
||||||
|
relative = file_path.relative_to(root_dir)
|
||||||
|
parts = relative.parts
|
||||||
|
if len(parts) > 0 and parts[0] in PACKAGES:
|
||||||
|
return parts[0]
|
||||||
|
return 'root'
|
||||||
|
except ValueError:
|
||||||
|
return 'root'
|
||||||
|
|
||||||
|
|
||||||
|
def count_lines(file_path: Path) -> int:
|
||||||
|
"""Count the number of lines in a file."""
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
|
return sum(1 for _ in f)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def find_code_files(root_dir: Path) -> List[Tuple[Path, int]]:
|
||||||
|
"""Find all code files and their line counts."""
|
||||||
|
files_with_counts = []
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root_dir):
|
||||||
|
# Remove skip directories from dirnames to prevent walking into them
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||||
|
|
||||||
|
for filename in filenames:
|
||||||
|
file_path = Path(dirpath) / filename
|
||||||
|
if file_path.suffix.lower() in CODE_EXTENSIONS:
|
||||||
|
line_count = count_lines(file_path)
|
||||||
|
files_with_counts.append((file_path, line_count))
|
||||||
|
|
||||||
|
return files_with_counts
|
||||||
|
|
||||||
|
|
||||||
|
# Regex patterns for TypeScript functions (exported and internal)
|
||||||
|
TS_FUNCTION_PATTERNS = [
|
||||||
|
# export function name(...) or function name(...)
|
||||||
|
re.compile(r'^(?:export\s+)?(?:async\s+)?function\s+(\w+)', re.MULTILINE),
|
||||||
|
# export const name = or const name =
|
||||||
|
re.compile(r'^(?:export\s+)?const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>', re.MULTILINE),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_functions(file_path: Path) -> Set[str]:
|
||||||
|
"""Extract function names from a TypeScript file."""
|
||||||
|
if file_path.suffix.lower() not in {'.ts', '.tsx'}:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
|
content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
functions = set()
|
||||||
|
for pattern in TS_FUNCTION_PATTERNS:
|
||||||
|
for match in pattern.finditer(content):
|
||||||
|
functions.add(match.group(1))
|
||||||
|
|
||||||
|
return functions
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate_functions(files: List[Tuple[Path, int]], root_dir: Path) -> Dict[str, List[Path]]:
|
||||||
|
"""Find function names that appear in multiple files."""
|
||||||
|
function_locations: Dict[str, List[Path]] = defaultdict(list)
|
||||||
|
|
||||||
|
for file_path, _ in files:
|
||||||
|
# Skip test files for duplicate detection
|
||||||
|
if any(file_path.name.endswith(pat) for pat in SKIP_DUPLICATE_FILE_PATTERNS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
functions = extract_functions(file_path)
|
||||||
|
for func in functions:
|
||||||
|
# Skip known common function names
|
||||||
|
if func in SKIP_DUPLICATE_FUNCTIONS:
|
||||||
|
continue
|
||||||
|
if any(func.startswith(prefix) for prefix in SKIP_DUPLICATE_PREFIXES):
|
||||||
|
continue
|
||||||
|
function_locations[func].append(file_path)
|
||||||
|
|
||||||
|
# Filter to only duplicates
|
||||||
|
return {name: paths for name, paths in function_locations.items() if len(paths) > 1}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='List the longest and shortest code files in a project'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-t', '--threshold',
|
||||||
|
type=int,
|
||||||
|
default=1000,
|
||||||
|
help='Warn about files longer than this many lines (default: 1000)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--min-threshold',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help='Warn about files shorter than this many lines (default: 10)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-n', '--top',
|
||||||
|
type=int,
|
||||||
|
default=20,
|
||||||
|
help='Show top N longest files (default: 20)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-b', '--bottom',
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
help='Show bottom N shortest files (default: 10)'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-d', '--directory',
|
||||||
|
type=str,
|
||||||
|
default='.',
|
||||||
|
help='Directory to scan (default: current directory)'
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root_dir = Path(args.directory).resolve()
|
||||||
|
print(f"\n📂 Scanning: {root_dir}\n")
|
||||||
|
|
||||||
|
# Find and sort files by line count
|
||||||
|
files = find_code_files(root_dir)
|
||||||
|
files_desc = sorted(files, key=lambda x: x[1], reverse=True)
|
||||||
|
files_asc = sorted(files, key=lambda x: x[1])
|
||||||
|
|
||||||
|
# Show top N longest files
|
||||||
|
top_files = files_desc[:args.top]
|
||||||
|
|
||||||
|
print(f"📊 Top {min(args.top, len(top_files))} longest code files:\n")
|
||||||
|
print(f"{'Lines':>8} {'File'}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
long_warnings = []
|
||||||
|
|
||||||
|
for file_path, line_count in top_files:
|
||||||
|
relative_path = file_path.relative_to(root_dir)
|
||||||
|
|
||||||
|
# Check if over threshold
|
||||||
|
if line_count >= args.threshold:
|
||||||
|
marker = " ⚠️"
|
||||||
|
long_warnings.append((relative_path, line_count))
|
||||||
|
else:
|
||||||
|
marker = ""
|
||||||
|
|
||||||
|
print(f"{line_count:>8} {relative_path}{marker}")
|
||||||
|
|
||||||
|
# Show bottom N shortest files
|
||||||
|
bottom_files = files_asc[:args.bottom]
|
||||||
|
|
||||||
|
print(f"\n📉 Bottom {min(args.bottom, len(bottom_files))} shortest code files:\n")
|
||||||
|
print(f"{'Lines':>8} {'File'}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
short_warnings = []
|
||||||
|
|
||||||
|
for file_path, line_count in bottom_files:
|
||||||
|
relative_path = file_path.relative_to(root_dir)
|
||||||
|
filename = file_path.name
|
||||||
|
|
||||||
|
# Skip known barrel exports and stubs
|
||||||
|
is_expected_short = (
|
||||||
|
filename in SKIP_SHORT_PATTERNS or
|
||||||
|
any(filename.endswith(suffix) for suffix in SKIP_SHORT_SUFFIXES)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if under threshold
|
||||||
|
if line_count <= args.min_threshold and not is_expected_short:
|
||||||
|
marker = " ⚠️"
|
||||||
|
short_warnings.append((relative_path, line_count))
|
||||||
|
else:
|
||||||
|
marker = ""
|
||||||
|
|
||||||
|
print(f"{line_count:>8} {relative_path}{marker}")
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total_files = len(files)
|
||||||
|
total_lines = sum(count for _, count in files)
|
||||||
|
|
||||||
|
print("-" * 60)
|
||||||
|
print(f"\n📈 Summary:")
|
||||||
|
print(f" Total code files: {total_files:,}")
|
||||||
|
print(f" Total lines: {total_lines:,}")
|
||||||
|
print(f" Average lines/file: {total_lines // total_files if total_files else 0:,}")
|
||||||
|
|
||||||
|
# Per-package breakdown
|
||||||
|
package_stats: dict[str, dict] = {}
|
||||||
|
for file_path, line_count in files:
|
||||||
|
pkg = get_package(file_path, root_dir)
|
||||||
|
if pkg not in package_stats:
|
||||||
|
package_stats[pkg] = {'files': 0, 'lines': 0}
|
||||||
|
package_stats[pkg]['files'] += 1
|
||||||
|
package_stats[pkg]['lines'] += line_count
|
||||||
|
|
||||||
|
print(f"\n📦 Per-package breakdown:\n")
|
||||||
|
print(f"{'Package':<15} {'Files':>8} {'Lines':>10} {'Avg':>8}")
|
||||||
|
print("-" * 45)
|
||||||
|
|
||||||
|
for pkg in sorted(package_stats.keys(), key=lambda p: package_stats[p]['lines'], reverse=True):
|
||||||
|
stats = package_stats[pkg]
|
||||||
|
avg = stats['lines'] // stats['files'] if stats['files'] else 0
|
||||||
|
print(f"{pkg:<15} {stats['files']:>8,} {stats['lines']:>10,} {avg:>8,}")
|
||||||
|
|
||||||
|
# Long file warnings
|
||||||
|
if long_warnings:
|
||||||
|
print(f"\n⚠️ Warning: {len(long_warnings)} file(s) exceed {args.threshold} lines (consider refactoring):")
|
||||||
|
for path, count in long_warnings:
|
||||||
|
print(f" - {path} ({count:,} lines)")
|
||||||
|
else:
|
||||||
|
print(f"\n✅ No files exceed {args.threshold} lines")
|
||||||
|
|
||||||
|
# Short file warnings
|
||||||
|
if short_warnings:
|
||||||
|
print(f"\n⚠️ Warning: {len(short_warnings)} file(s) are {args.min_threshold} lines or less (check if needed):")
|
||||||
|
for path, count in short_warnings:
|
||||||
|
print(f" - {path} ({count} lines)")
|
||||||
|
else:
|
||||||
|
print(f"\n✅ No files are {args.min_threshold} lines or less")
|
||||||
|
|
||||||
|
# Duplicate function names
|
||||||
|
duplicates = find_duplicate_functions(files, root_dir)
|
||||||
|
if duplicates:
|
||||||
|
print(f"\n⚠️ Warning: {len(duplicates)} function name(s) appear in multiple files (consider renaming):")
|
||||||
|
for func_name in sorted(duplicates.keys()):
|
||||||
|
paths = duplicates[func_name]
|
||||||
|
print(f" - {func_name}:")
|
||||||
|
for path in paths:
|
||||||
|
print(f" {path.relative_to(root_dir)}")
|
||||||
|
else:
|
||||||
|
print(f"\n✅ No duplicate function names")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
|
||||||
import {
|
import {
|
||||||
deleteSession,
|
deleteSession,
|
||||||
drainSession,
|
drainSession,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
} from "./bash-process-registry.js";
|
} from "./bash-process-registry.js";
|
||||||
import {
|
import {
|
||||||
deriveSessionName,
|
deriveSessionName,
|
||||||
formatDuration,
|
|
||||||
killSession,
|
killSession,
|
||||||
pad,
|
pad,
|
||||||
sliceLogLines,
|
sliceLogLines,
|
||||||
@@ -118,7 +118,7 @@ export function createProcessTool(
|
|||||||
.toSorted((a, b) => b.startedAt - a.startedAt)
|
.toSorted((a, b) => b.startedAt - a.startedAt)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
const label = s.name ? truncateMiddle(s.name, 80) : truncateMiddle(s.command, 120);
|
||||||
return `${s.sessionId} ${pad(s.status, 9)} ${formatDuration(s.runtimeMs)} :: ${label}`;
|
return `${s.sessionId} ${pad(s.status, 9)} ${formatDurationCompact(s.runtimeMs) ?? "n/a"} :: ${label}`;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -244,19 +244,6 @@ function stripQuotes(value: string): string {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(ms: number) {
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${ms}ms`;
|
|
||||||
}
|
|
||||||
const seconds = Math.floor(ms / 1000);
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const rem = seconds % 60;
|
|
||||||
return `${minutes}m${rem.toString().padStart(2, "0")}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pad(str: string, width: number) {
|
export function pad(str: string, width: number) {
|
||||||
if (str.length >= width) {
|
if (str.length >= width) {
|
||||||
return str;
|
return str;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
@@ -25,23 +26,6 @@ import {
|
|||||||
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
|
||||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||||
|
|
||||||
function formatDurationShort(valueMs?: number) {
|
|
||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.round(valueMs / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h${minutes}m`;
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
return `${minutes}m${seconds}s`;
|
|
||||||
}
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTokenCount(value?: number) {
|
function formatTokenCount(value?: number) {
|
||||||
if (!value || !Number.isFinite(value)) {
|
if (!value || !Number.isFinite(value)) {
|
||||||
return "0";
|
return "0";
|
||||||
@@ -267,7 +251,7 @@ async function buildSubagentStatsLine(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const runtime = formatDurationShort(runtimeMs);
|
const runtime = formatDurationCompact(runtimeMs);
|
||||||
parts.push(`runtime ${runtime ?? "n/a"}`);
|
parts.push(`runtime ${runtime ?? "n/a"}`);
|
||||||
if (typeof total === "number") {
|
if (typeof total === "number") {
|
||||||
const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a";
|
const inputText = typeof input === "number" ? formatTokenCount(input) : "n/a";
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||||
import { normalizeChatType } from "../channels/chat-type.js";
|
import { normalizeChatType } from "../channels/chat-type.js";
|
||||||
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
|
||||||
|
import {
|
||||||
|
resolveTimezone,
|
||||||
|
formatUtcTimestamp,
|
||||||
|
formatZonedTimestamp,
|
||||||
|
} from "../infra/format-time/format-datetime.ts";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
|
|
||||||
export type AgentEnvelopeParams = {
|
export type AgentEnvelopeParams = {
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -66,15 +72,6 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExplicitTimezone(value: string): string | undefined {
|
|
||||||
try {
|
|
||||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
|
||||||
return value;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||||
const trimmed = options.timezone?.trim();
|
const trimmed = options.timezone?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -90,46 +87,10 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
|
|||||||
if (lowered === "user") {
|
if (lowered === "user") {
|
||||||
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
|
||||||
}
|
}
|
||||||
const explicit = resolveExplicitTimezone(trimmed);
|
const explicit = resolveTimezone(trimmed);
|
||||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUtcTimestamp(date: Date): string {
|
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
||||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
timeZoneName: "short",
|
|
||||||
}).formatToParts(date);
|
|
||||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
|
||||||
const yyyy = pick("year");
|
|
||||||
const mm = pick("month");
|
|
||||||
const dd = pick("day");
|
|
||||||
const hh = pick("hour");
|
|
||||||
const min = pick("minute");
|
|
||||||
const tz = [...parts]
|
|
||||||
.toReversed()
|
|
||||||
.find((part) => part.type === "timeZoneName")
|
|
||||||
?.value?.trim();
|
|
||||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(
|
function formatTimestamp(
|
||||||
ts: number | Date | undefined,
|
ts: number | Date | undefined,
|
||||||
options?: EnvelopeFormatOptions,
|
options?: EnvelopeFormatOptions,
|
||||||
@@ -152,47 +113,27 @@ function formatTimestamp(
|
|||||||
if (zone.mode === "local") {
|
if (zone.mode === "local") {
|
||||||
return formatZonedTimestamp(date);
|
return formatZonedTimestamp(date);
|
||||||
}
|
}
|
||||||
return formatZonedTimestamp(date, zone.timeZone);
|
return formatZonedTimestamp(date, { timeZone: zone.timeZone });
|
||||||
}
|
|
||||||
|
|
||||||
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
|
|
||||||
const elapsedMs = currentMs - previousMs;
|
|
||||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seconds = Math.floor(elapsedMs / 1000);
|
|
||||||
if (seconds < 60) {
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) {
|
|
||||||
return `${hours}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||||
const channel = params.channel?.trim() || "Channel";
|
const channel = params.channel?.trim() || "Channel";
|
||||||
const parts: string[] = [channel];
|
const parts: string[] = [channel];
|
||||||
const resolved = normalizeEnvelopeOptions(params.envelope);
|
const resolved = normalizeEnvelopeOptions(params.envelope);
|
||||||
const elapsed =
|
let elapsed: string | undefined;
|
||||||
resolved.includeElapsed && params.timestamp && params.previousTimestamp
|
if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) {
|
||||||
? formatElapsedTime(
|
const currentMs =
|
||||||
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
|
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp;
|
||||||
params.previousTimestamp instanceof Date
|
const previousMs =
|
||||||
? params.previousTimestamp.getTime()
|
params.previousTimestamp instanceof Date
|
||||||
: params.previousTimestamp,
|
? params.previousTimestamp.getTime()
|
||||||
)
|
: params.previousTimestamp;
|
||||||
: undefined;
|
const elapsedMs = currentMs - previousMs;
|
||||||
|
elapsed =
|
||||||
|
Number.isFinite(elapsedMs) && elapsedMs >= 0
|
||||||
|
? formatTimeAgo(elapsedMs, { suffix: false })
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
if (params.from?.trim()) {
|
if (params.from?.trim()) {
|
||||||
const from = params.from.trim();
|
const from = params.from.trim();
|
||||||
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
parts.push(elapsed ? `${from} +${elapsed}` : from);
|
||||||
|
|||||||
@@ -14,17 +14,13 @@ import {
|
|||||||
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { formatDurationCompact } from "../../infra/format-time/format-duration.ts";
|
||||||
|
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||||
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
import { parseAgentSessionKey } from "../../routing/session-key.js";
|
||||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||||
import { stopSubagentsForRequester } from "./abort.js";
|
import { stopSubagentsForRequester } from "./abort.js";
|
||||||
import { clearSessionQueues } from "./queue.js";
|
import { clearSessionQueues } from "./queue.js";
|
||||||
import {
|
import { formatRunLabel, formatRunStatus, sortSubagentRuns } from "./subagents-utils.js";
|
||||||
formatAgeShort,
|
|
||||||
formatDurationShort,
|
|
||||||
formatRunLabel,
|
|
||||||
formatRunStatus,
|
|
||||||
sortSubagentRuns,
|
|
||||||
} from "./subagents-utils.js";
|
|
||||||
|
|
||||||
type SubagentTargetResolution = {
|
type SubagentTargetResolution = {
|
||||||
entry?: SubagentRunRecord;
|
entry?: SubagentRunRecord;
|
||||||
@@ -45,7 +41,7 @@ function formatTimestampWithAge(valueMs?: number) {
|
|||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
||||||
return "n/a";
|
return "n/a";
|
||||||
}
|
}
|
||||||
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
|
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
|
||||||
@@ -214,8 +210,8 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
|||||||
const label = formatRunLabel(entry);
|
const label = formatRunLabel(entry);
|
||||||
const runtime =
|
const runtime =
|
||||||
entry.endedAt && entry.startedAt
|
entry.endedAt && entry.startedAt
|
||||||
? formatDurationShort(entry.endedAt - entry.startedAt)
|
? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a")
|
||||||
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
|
: formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" });
|
||||||
const runId = entry.runId.slice(0, 8);
|
const runId = entry.runId.slice(0, 8);
|
||||||
lines.push(
|
lines.push(
|
||||||
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
|
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
|
||||||
@@ -296,7 +292,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
|
|||||||
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
|
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
|
||||||
const runtime =
|
const runtime =
|
||||||
run.startedAt && Number.isFinite(run.startedAt)
|
run.startedAt && Number.isFinite(run.startedAt)
|
||||||
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
|
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
|
||||||
: "n/a";
|
: "n/a";
|
||||||
const outcome = run.outcome
|
const outcome = run.outcome
|
||||||
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
|||||||
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
import { buildChannelSummary } from "../../infra/channel-summary.js";
|
||||||
|
import {
|
||||||
|
resolveTimezone,
|
||||||
|
formatUtcTimestamp,
|
||||||
|
formatZonedTimestamp,
|
||||||
|
} from "../../infra/format-time/format-datetime.ts";
|
||||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||||
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
import { drainSystemEventEntries } from "../../infra/system-events.js";
|
||||||
|
|
||||||
@@ -39,15 +44,6 @@ export async function prependSystemEvents(params: {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveExplicitTimezone = (value: string): string | undefined => {
|
|
||||||
try {
|
|
||||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
|
||||||
return value;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
|
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
|
||||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -66,49 +62,10 @@ export async function prependSystemEvents(params: {
|
|||||||
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const explicit = resolveExplicitTimezone(raw);
|
const explicit = resolveTimezone(raw);
|
||||||
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatUtcTimestamp = (date: Date): string => {
|
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
||||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
|
||||||
const sec = String(date.getUTCSeconds()).padStart(2, "0");
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
timeZoneName: "short",
|
|
||||||
}).formatToParts(date);
|
|
||||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
|
||||||
const yyyy = pick("year");
|
|
||||||
const mm = pick("month");
|
|
||||||
const dd = pick("day");
|
|
||||||
const hh = pick("hour");
|
|
||||||
const min = pick("minute");
|
|
||||||
const sec = pick("second");
|
|
||||||
const tz = [...parts]
|
|
||||||
.toReversed()
|
|
||||||
.find((part) => part.type === "timeZoneName")
|
|
||||||
?.value?.trim();
|
|
||||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
|
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -116,12 +73,15 @@ export async function prependSystemEvents(params: {
|
|||||||
}
|
}
|
||||||
const zone = resolveSystemEventTimezone(cfg);
|
const zone = resolveSystemEventTimezone(cfg);
|
||||||
if (zone.mode === "utc") {
|
if (zone.mode === "utc") {
|
||||||
return formatUtcTimestamp(date);
|
return formatUtcTimestamp(date, { displaySeconds: true });
|
||||||
}
|
}
|
||||||
if (zone.mode === "local") {
|
if (zone.mode === "local") {
|
||||||
return formatZonedTimestamp(date) ?? "unknown-time";
|
return formatZonedTimestamp(date, { displaySeconds: true }) ?? "unknown-time";
|
||||||
}
|
}
|
||||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
return (
|
||||||
|
formatZonedTimestamp(date, { timeZone: zone.timeZone, displaySeconds: true }) ??
|
||||||
|
"unknown-time"
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const systemLines: string[] = [];
|
const systemLines: string[] = [];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||||
|
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
|
||||||
import {
|
import {
|
||||||
formatDurationShort,
|
|
||||||
formatRunLabel,
|
formatRunLabel,
|
||||||
formatRunStatus,
|
formatRunStatus,
|
||||||
resolveSubagentLabel,
|
resolveSubagentLabel,
|
||||||
@@ -54,8 +54,8 @@ describe("subagents utils", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats duration short for seconds and minutes", () => {
|
it("formats duration compact for seconds and minutes", () => {
|
||||||
expect(formatDurationShort(45_000)).toBe("45s");
|
expect(formatDurationCompact(45_000)).toBe("45s");
|
||||||
expect(formatDurationShort(65_000)).toBe("1m5s");
|
expect(formatDurationCompact(65_000)).toBe("1m5s");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,42 +1,6 @@
|
|||||||
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
|
||||||
import { truncateUtf16Safe } from "../../utils.js";
|
import { truncateUtf16Safe } from "../../utils.js";
|
||||||
|
|
||||||
export function formatDurationShort(valueMs?: number) {
|
|
||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
|
||||||
return "n/a";
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.round(valueMs / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h${minutes}m`;
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
return `${minutes}m${seconds}s`;
|
|
||||||
}
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAgeShort(valueMs?: number) {
|
|
||||||
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
|
|
||||||
return "n/a";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(valueMs / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") {
|
export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") {
|
||||||
const raw = entry.label?.trim() || entry.task?.trim() || "";
|
const raw = entry.label?.trim() || entry.task?.trim() || "";
|
||||||
return raw || fallback;
|
return raw || fallback;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
type SessionScope,
|
type SessionScope,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||||
import { listPluginCommands } from "../plugins/commands.js";
|
import { listPluginCommands } from "../plugins/commands.js";
|
||||||
import {
|
import {
|
||||||
@@ -134,25 +135,6 @@ export const formatContextUsageShort = (
|
|||||||
contextTokens: number | null | undefined,
|
contextTokens: number | null | undefined,
|
||||||
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
|
||||||
|
|
||||||
const formatAge = (ms?: number | null) => {
|
|
||||||
if (!ms || ms < 0) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(ms / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatQueueDetails = (queue?: QueueStatus) => {
|
const formatQueueDetails = (queue?: QueueStatus) => {
|
||||||
if (!queue) {
|
if (!queue) {
|
||||||
return "";
|
return "";
|
||||||
@@ -386,7 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
const updatedAt = entry?.updatedAt;
|
const updatedAt = entry?.updatedAt;
|
||||||
const sessionLine = [
|
const sessionLine = [
|
||||||
`Session: ${args.sessionKey ?? "unknown"}`,
|
`Session: ${args.sessionKey ?? "unknown"}`,
|
||||||
typeof updatedAt === "number" ? `updated ${formatAge(now - updatedAt)}` : "no activity",
|
typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" • ");
|
.join(" • ");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { CronJob, CronSchedule } from "../../cron/types.js";
|
|||||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||||
|
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
import { callGatewayFromCli } from "../gateway-rpc.js";
|
import { callGatewayFromCli } from "../gateway-rpc.js";
|
||||||
@@ -107,19 +108,6 @@ const formatIsoMinute = (iso: string) => {
|
|||||||
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
|
return `${isoStr.slice(0, 10)} ${isoStr.slice(11, 16)}Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (ms: number) => {
|
|
||||||
if (ms < 60_000) {
|
|
||||||
return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
||||||
}
|
|
||||||
if (ms < 3_600_000) {
|
|
||||||
return `${Math.round(ms / 60_000)}m`;
|
|
||||||
}
|
|
||||||
if (ms < 86_400_000) {
|
|
||||||
return `${Math.round(ms / 3_600_000)}h`;
|
|
||||||
}
|
|
||||||
return `${Math.round(ms / 86_400_000)}d`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSpan = (ms: number) => {
|
const formatSpan = (ms: number) => {
|
||||||
if (ms < 60_000) {
|
if (ms < 60_000) {
|
||||||
return "<1m";
|
return "<1m";
|
||||||
@@ -147,7 +135,7 @@ const formatSchedule = (schedule: CronSchedule) => {
|
|||||||
return `at ${formatIsoMinute(schedule.at)}`;
|
return `at ${formatIsoMinute(schedule.at)}`;
|
||||||
}
|
}
|
||||||
if (schedule.kind === "every") {
|
if (schedule.kind === "every") {
|
||||||
return `every ${formatDuration(schedule.everyMs)}`;
|
return `every ${formatDurationHuman(schedule.everyMs)}`;
|
||||||
}
|
}
|
||||||
return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
|
return schedule.tz ? `cron ${schedule.expr} @ ${schedule.tz}` : `cron ${schedule.expr}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
@@ -49,23 +50,6 @@ type DevicePairingList = {
|
|||||||
paired?: PairedDevice[];
|
paired?: PairedDevice[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAge(msAgo: number) {
|
|
||||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
|
||||||
if (s < 60) {
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) {
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) {
|
|
||||||
return `${h}h`;
|
|
||||||
}
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return `${d}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
const devicesCallOpts = (cmd: Command, defaults?: { timeoutMs?: number }) =>
|
||||||
cmd
|
cmd
|
||||||
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
|
||||||
@@ -147,7 +131,7 @@ export function registerDevicesCli(program: Command) {
|
|||||||
Device: req.displayName || req.deviceId,
|
Device: req.displayName || req.deviceId,
|
||||||
Role: req.role ?? "",
|
Role: req.role ?? "",
|
||||||
IP: req.remoteIp ?? "",
|
IP: req.remoteIp ?? "",
|
||||||
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
|
Age: typeof req.ts === "number" ? formatTimeAgo(Date.now() - req.ts) : "",
|
||||||
Flags: req.isRepair ? "repair" : "",
|
Flags: req.isRepair ? "repair" : "",
|
||||||
})),
|
})),
|
||||||
}).trimEnd(),
|
}).trimEnd(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ExecApprovalsAgent,
|
type ExecApprovalsAgent,
|
||||||
type ExecApprovalsFile,
|
type ExecApprovalsFile,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
@@ -31,23 +32,6 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
|
|||||||
agent?: string;
|
agent?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatAge(msAgo: number) {
|
|
||||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
|
||||||
if (s < 60) {
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) {
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) {
|
|
||||||
return `${h}h`;
|
|
||||||
}
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return `${d}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readStdin(): Promise<string> {
|
async function readStdin(): Promise<string> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of process.stdin) {
|
for await (const chunk of process.stdin) {
|
||||||
@@ -142,7 +126,7 @@ function renderApprovalsSnapshot(snapshot: ExecApprovalsSnapshot, targetLabel: s
|
|||||||
Target: targetLabel,
|
Target: targetLabel,
|
||||||
Agent: agentId,
|
Agent: agentId,
|
||||||
Pattern: pattern,
|
Pattern: pattern,
|
||||||
LastUsed: lastUsedAt ? `${formatAge(Math.max(0, now - lastUsedAt))} ago` : muted("unknown"),
|
LastUsed: lastUsedAt ? formatTimeAgo(Math.max(0, now - lastUsedAt)) : muted("unknown"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./types.js";
|
import type { NodeListNode, PairedNode, PairingList, PendingRequest } from "./types.js";
|
||||||
|
|
||||||
export function formatAge(msAgo: number) {
|
|
||||||
const s = Math.max(0, Math.floor(msAgo / 1000));
|
|
||||||
if (s < 60) {
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) {
|
|
||||||
return `${m}m`;
|
|
||||||
}
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) {
|
|
||||||
return `${h}h`;
|
|
||||||
}
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return `${d}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parsePairingList(value: unknown): PairingList {
|
export function parsePairingList(value: unknown): PairingList {
|
||||||
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
const obj = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
|
||||||
const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : [];
|
const pending = Array.isArray(obj.pending) ? (obj.pending as PendingRequest[]) : [];
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { renderTable } from "../../terminal/table.js";
|
import { renderTable } from "../../terminal/table.js";
|
||||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||||
import { formatAge, parsePairingList } from "./format.js";
|
import { parsePairingList } from "./format.js";
|
||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
|
|
||||||
export function registerNodesPairingCommands(nodes: Command) {
|
export function registerNodesPairingCommands(nodes: Command) {
|
||||||
@@ -32,9 +33,7 @@ export function registerNodesPairingCommands(nodes: Command) {
|
|||||||
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
||||||
IP: r.remoteIp ?? "",
|
IP: r.remoteIp ?? "",
|
||||||
Requested:
|
Requested:
|
||||||
typeof r.ts === "number"
|
typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : muted("unknown"),
|
||||||
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
|
||||||
: muted("unknown"),
|
|
||||||
Repair: r.isRepair ? warn("yes") : "",
|
Repair: r.isRepair ? warn("yes") : "",
|
||||||
}));
|
}));
|
||||||
defaultRuntime.log(heading("Pending"));
|
defaultRuntime.log(heading("Pending"));
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { renderTable } from "../../terminal/table.js";
|
import { renderTable } from "../../terminal/table.js";
|
||||||
import { shortenHomeInString } from "../../utils.js";
|
import { shortenHomeInString } from "../../utils.js";
|
||||||
import { parseDurationMs } from "../parse-duration.js";
|
import { parseDurationMs } from "../parse-duration.js";
|
||||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||||
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
|
|
||||||
function formatVersionLabel(raw: string) {
|
function formatVersionLabel(raw: string) {
|
||||||
@@ -178,7 +179,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
const connected = n.connected ? ok("connected") : muted("disconnected");
|
const connected = n.connected ? ok("connected") : muted("disconnected");
|
||||||
const since =
|
const since =
|
||||||
typeof n.connectedAtMs === "number"
|
typeof n.connectedAtMs === "number"
|
||||||
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
|
? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -361,7 +362,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
IP: r.remoteIp ?? "",
|
IP: r.remoteIp ?? "",
|
||||||
Requested:
|
Requested:
|
||||||
typeof r.ts === "number"
|
typeof r.ts === "number"
|
||||||
? `${formatAge(Math.max(0, now - r.ts))} ago`
|
? formatTimeAgo(Math.max(0, now - r.ts))
|
||||||
: muted("unknown"),
|
: muted("unknown"),
|
||||||
Repair: r.isRepair ? warn("yes") : "",
|
Repair: r.isRepair ? warn("yes") : "",
|
||||||
}));
|
}));
|
||||||
@@ -397,7 +398,7 @@ export function registerNodesStatusCommands(nodes: Command) {
|
|||||||
IP: n.remoteIp ?? "",
|
IP: n.remoteIp ?? "",
|
||||||
LastConnect:
|
LastConnect:
|
||||||
typeof lastConnectedAtMs === "number"
|
typeof lastConnectedAtMs === "number"
|
||||||
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
|
? formatTimeAgo(Math.max(0, now - lastConnectedAtMs))
|
||||||
: muted("unknown"),
|
: muted("unknown"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "../commands/status.update.js";
|
} from "../commands/status.update.js";
|
||||||
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
|
||||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||||
import { trimLogTail } from "../infra/restart-sentinel.js";
|
import { trimLogTail } from "../infra/restart-sentinel.js";
|
||||||
import { parseSemver } from "../infra/runtime-guard.js";
|
import { parseSemver } from "../infra/runtime-guard.js";
|
||||||
@@ -575,7 +576,7 @@ function createUpdateProgress(enabled: boolean): ProgressController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = getStepLabel(step);
|
const label = getStepLabel(step);
|
||||||
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
|
||||||
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
|
const icon = step.exitCode === 0 ? theme.success("\u2713") : theme.error("\u2717");
|
||||||
|
|
||||||
currentSpinner.stop(`${icon} ${label} ${duration}`);
|
currentSpinner.stop(`${icon} ${label} ${duration}`);
|
||||||
@@ -603,14 +604,6 @@ function createUpdateProgress(enabled: boolean): ProgressController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${ms}ms`;
|
|
||||||
}
|
|
||||||
const seconds = (ms / 1000).toFixed(1);
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStepStatus(exitCode: number | null): string {
|
function formatStepStatus(exitCode: number | null): string {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
return theme.success("\u2713");
|
return theme.success("\u2713");
|
||||||
@@ -668,7 +661,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
|||||||
defaultRuntime.log(theme.heading("Steps:"));
|
defaultRuntime.log(theme.heading("Steps:"));
|
||||||
for (const step of result.steps) {
|
for (const step of result.steps) {
|
||||||
const status = formatStepStatus(step.exitCode);
|
const status = formatStepStatus(step.exitCode);
|
||||||
const duration = theme.muted(`(${formatDuration(step.durationMs)})`);
|
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
|
||||||
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
|
defaultRuntime.log(` ${status} ${step.name} ${duration}`);
|
||||||
|
|
||||||
if (step.exitCode !== 0 && step.stderrTail) {
|
if (step.exitCode !== 0 && step.stderrTail) {
|
||||||
@@ -683,7 +676,7 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultRuntime.log("");
|
defaultRuntime.log("");
|
||||||
defaultRuntime.log(`Total time: ${theme.muted(formatDuration(result.durationMs))}`);
|
defaultRuntime.log(`Total time: ${theme.muted(formatDurationPrecise(result.durationMs))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
|||||||
import { withProgress } from "../../cli/progress.js";
|
import { withProgress } from "../../cli/progress.js";
|
||||||
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
|
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import { formatAge } from "../../infra/channel-summary.js";
|
|
||||||
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
|
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
|
||||||
|
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { theme } from "../../terminal/theme.js";
|
import { theme } from "../../terminal/theme.js";
|
||||||
@@ -48,10 +48,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
|||||||
? account.lastOutboundAt
|
? account.lastOutboundAt
|
||||||
: null;
|
: null;
|
||||||
if (inboundAt) {
|
if (inboundAt) {
|
||||||
bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
|
bits.push(`in:${formatTimeAgo(Date.now() - inboundAt)}`);
|
||||||
}
|
}
|
||||||
if (outboundAt) {
|
if (outboundAt) {
|
||||||
bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
|
bits.push(`out:${formatTimeAgo(Date.now() - outboundAt)}`);
|
||||||
}
|
}
|
||||||
if (typeof account.mode === "string" && account.mode.length > 0) {
|
if (typeof account.mode === "string" && account.mode.length > 0) {
|
||||||
bits.push(`mode:${account.mode}`);
|
bits.push(`mode:${account.mode}`);
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js";
|
import type { SandboxBrowserInfo, SandboxContainerInfo } from "../agents/sandbox.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import {
|
import { formatDurationCompact } from "../infra/format-time/format-duration.ts";
|
||||||
formatAge,
|
import { formatImageMatch, formatSimpleStatus, formatStatus } from "./sandbox-formatters.js";
|
||||||
formatImageMatch,
|
|
||||||
formatSimpleStatus,
|
|
||||||
formatStatus,
|
|
||||||
} from "./sandbox-formatters.js";
|
|
||||||
|
|
||||||
type DisplayConfig<T> = {
|
type DisplayConfig<T> = {
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
@@ -40,8 +36,12 @@ export function displayContainers(containers: SandboxContainerInfo[], runtime: R
|
|||||||
rt.log(` ${container.containerName}`);
|
rt.log(` ${container.containerName}`);
|
||||||
rt.log(` Status: ${formatStatus(container.running)}`);
|
rt.log(` Status: ${formatStatus(container.running)}`);
|
||||||
rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`);
|
rt.log(` Image: ${container.image} ${formatImageMatch(container.imageMatch)}`);
|
||||||
rt.log(` Age: ${formatAge(Date.now() - container.createdAtMs)}`);
|
rt.log(
|
||||||
rt.log(` Idle: ${formatAge(Date.now() - container.lastUsedAtMs)}`);
|
` Age: ${formatDurationCompact(Date.now() - container.createdAtMs, { spaced: true }) ?? "0s"}`,
|
||||||
|
);
|
||||||
|
rt.log(
|
||||||
|
` Idle: ${formatDurationCompact(Date.now() - container.lastUsedAtMs, { spaced: true }) ?? "0s"}`,
|
||||||
|
);
|
||||||
rt.log(` Session: ${container.sessionKey}`);
|
rt.log(` Session: ${container.sessionKey}`);
|
||||||
rt.log("");
|
rt.log("");
|
||||||
},
|
},
|
||||||
@@ -64,8 +64,12 @@ export function displayBrowsers(browsers: SandboxBrowserInfo[], runtime: Runtime
|
|||||||
if (browser.noVncPort) {
|
if (browser.noVncPort) {
|
||||||
rt.log(` noVNC: ${browser.noVncPort}`);
|
rt.log(` noVNC: ${browser.noVncPort}`);
|
||||||
}
|
}
|
||||||
rt.log(` Age: ${formatAge(Date.now() - browser.createdAtMs)}`);
|
rt.log(
|
||||||
rt.log(` Idle: ${formatAge(Date.now() - browser.lastUsedAtMs)}`);
|
` Age: ${formatDurationCompact(Date.now() - browser.createdAtMs, { spaced: true }) ?? "0s"}`,
|
||||||
|
);
|
||||||
|
rt.log(
|
||||||
|
` Idle: ${formatDurationCompact(Date.now() - browser.lastUsedAtMs, { spaced: true }) ?? "0s"}`,
|
||||||
|
);
|
||||||
rt.log(` Session: ${browser.sessionKey}`);
|
rt.log(` Session: ${browser.sessionKey}`);
|
||||||
rt.log("");
|
rt.log("");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatDurationCompact } from "../infra/format-time/format-duration.js";
|
||||||
import {
|
import {
|
||||||
countMismatches,
|
countMismatches,
|
||||||
countRunning,
|
countRunning,
|
||||||
formatAge,
|
|
||||||
formatImageMatch,
|
formatImageMatch,
|
||||||
formatSimpleStatus,
|
formatSimpleStatus,
|
||||||
formatStatus,
|
formatStatus,
|
||||||
} from "./sandbox-formatters.js";
|
} from "./sandbox-formatters.js";
|
||||||
|
|
||||||
|
/** Helper matching old formatAge behavior: spaced compound duration */
|
||||||
|
const formatAge = (ms: number) => formatDurationCompact(ms, { spaced: true }) ?? "0s";
|
||||||
|
|
||||||
describe("sandbox-formatters", () => {
|
describe("sandbox-formatters", () => {
|
||||||
describe("formatStatus", () => {
|
describe("formatStatus", () => {
|
||||||
it("should format running status", () => {
|
it("should format running status", () => {
|
||||||
@@ -47,21 +50,21 @@ describe("sandbox-formatters", () => {
|
|||||||
|
|
||||||
it("should format minutes", () => {
|
it("should format minutes", () => {
|
||||||
expect(formatAge(60000)).toBe("1m");
|
expect(formatAge(60000)).toBe("1m");
|
||||||
expect(formatAge(90000)).toBe("1m");
|
expect(formatAge(90000)).toBe("1m 30s"); // 90 seconds = 1m 30s
|
||||||
expect(formatAge(300000)).toBe("5m");
|
expect(formatAge(300000)).toBe("5m");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format hours and minutes", () => {
|
it("should format hours and minutes", () => {
|
||||||
expect(formatAge(3600000)).toBe("1h 0m");
|
expect(formatAge(3600000)).toBe("1h");
|
||||||
expect(formatAge(3660000)).toBe("1h 1m");
|
expect(formatAge(3660000)).toBe("1h 1m");
|
||||||
expect(formatAge(7200000)).toBe("2h 0m");
|
expect(formatAge(7200000)).toBe("2h");
|
||||||
expect(formatAge(5400000)).toBe("1h 30m");
|
expect(formatAge(5400000)).toBe("1h 30m");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should format days and hours", () => {
|
it("should format days and hours", () => {
|
||||||
expect(formatAge(86400000)).toBe("1d 0h");
|
expect(formatAge(86400000)).toBe("1d");
|
||||||
expect(formatAge(90000000)).toBe("1d 1h");
|
expect(formatAge(90000000)).toBe("1d 1h");
|
||||||
expect(formatAge(172800000)).toBe("2d 0h");
|
expect(formatAge(172800000)).toBe("2d");
|
||||||
expect(formatAge(183600000)).toBe("2d 3h");
|
expect(formatAge(183600000)).toBe("2d 3h");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,9 +73,9 @@ describe("sandbox-formatters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle edge cases", () => {
|
it("should handle edge cases", () => {
|
||||||
expect(formatAge(59999)).toBe("59s"); // Just under 1 minute
|
expect(formatAge(59999)).toBe("1m"); // Rounds to 1 minute exactly
|
||||||
expect(formatAge(3599999)).toBe("59m"); // Just under 1 hour
|
expect(formatAge(3599999)).toBe("1h"); // Rounds to 1 hour exactly
|
||||||
expect(formatAge(86399999)).toBe("23h 59m"); // Just under 1 day
|
expect(formatAge(86399999)).toBe("1d"); // Rounds to 1 day exactly
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,6 @@ export function formatImageMatch(matches: boolean): string {
|
|||||||
return matches ? "✓" : "⚠️ mismatch";
|
return matches ? "✓" : "⚠️ mismatch";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAge(ms: number): string {
|
|
||||||
const seconds = Math.floor(ms / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
return `${days}d ${hours % 24}h`;
|
|
||||||
}
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes % 60}m`;
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
return `${minutes}m`;
|
|
||||||
}
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard and counter utilities
|
* Type guard and counter utilities
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
type SessionRow = {
|
type SessionRow = {
|
||||||
@@ -90,7 +91,7 @@ const formatKindCell = (kind: SessionRow["kind"], rich: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
|
const formatAgeCell = (updatedAt: number | null | undefined, rich: boolean) => {
|
||||||
const ageLabel = updatedAt ? formatAge(Date.now() - updatedAt) : "unknown";
|
const ageLabel = updatedAt ? formatTimeAgo(Date.now() - updatedAt) : "unknown";
|
||||||
const padded = ageLabel.padEnd(AGE_PAD);
|
const padded = ageLabel.padEnd(AGE_PAD);
|
||||||
return rich ? theme.muted(padded) : padded;
|
return rich ? theme.muted(padded) : padded;
|
||||||
};
|
};
|
||||||
@@ -116,25 +117,6 @@ const formatFlagsCell = (row: SessionRow, rich: boolean) => {
|
|||||||
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
|
return label.length === 0 ? "" : rich ? theme.muted(label) : label;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatAge = (ms: number | null | undefined) => {
|
|
||||||
if (!ms || ms < 0) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(ms / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
|
function classifyKey(key: string, entry?: SessionEntry): SessionRow["kind"] {
|
||||||
if (key === "global") {
|
if (key === "global") {
|
||||||
return "global";
|
return "global";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { VERSION } from "../version.js";
|
|||||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||||
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
import { getAgentLocalStatuses } from "./status-all/agents.js";
|
||||||
import { buildChannelsTable } from "./status-all/channels.js";
|
import { buildChannelsTable } from "./status-all/channels.js";
|
||||||
import { formatDuration, formatGatewayAuthUsed } from "./status-all/format.js";
|
import { formatDurationPrecise, formatGatewayAuthUsed } from "./status-all/format.js";
|
||||||
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
|
import { pickGatewaySelfPresence } from "./status-all/gateway.js";
|
||||||
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
|
import { buildStatusAllReportLines } from "./status-all/report-lines.js";
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ export async function statusAllCommand(
|
|||||||
|
|
||||||
const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url;
|
const gatewayTarget = remoteUrlMissing ? `fallback ${connection.url}` : connection.url;
|
||||||
const gatewayStatus = gatewayReachable
|
const gatewayStatus = gatewayReachable
|
||||||
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
|
? `reachable ${formatDurationPrecise(gatewayProbe?.connectLatencyMs ?? 0)}`
|
||||||
: gatewayProbe?.error
|
: gatewayProbe?.error
|
||||||
? `unreachable (${gatewayProbe.error})`
|
? `unreachable (${gatewayProbe.error})`
|
||||||
: "unreachable";
|
: "unreachable";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
|||||||
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js";
|
||||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||||||
import { formatAge } from "./format.js";
|
import { formatTimeAgo } from "./format.js";
|
||||||
|
|
||||||
export type ChannelRow = {
|
export type ChannelRow = {
|
||||||
id: ChannelId;
|
id: ChannelId;
|
||||||
@@ -436,7 +436,7 @@ export async function buildChannelsTable(
|
|||||||
extra.push(link.selfE164);
|
extra.push(link.selfE164);
|
||||||
}
|
}
|
||||||
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
|
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
|
||||||
extra.push(`auth ${formatAge(link.authAgeMs)}`);
|
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
|
||||||
}
|
}
|
||||||
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
|
if (accounts.length > 1 || plugin.meta.forceAccountBinding) {
|
||||||
extra.push(`accounts ${accounts.length || 1}`);
|
extra.push(`accounts ${accounts.length || 1}`);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RestartSentinelPayload,
|
type RestartSentinelPayload,
|
||||||
summarizeRestartSentinel,
|
summarizeRestartSentinel,
|
||||||
} from "../../infra/restart-sentinel.js";
|
} from "../../infra/restart-sentinel.js";
|
||||||
import { formatAge, redactSecrets } from "./format.js";
|
import { formatTimeAgo, redactSecrets } from "./format.js";
|
||||||
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
|
import { readFileTailLines, summarizeLogTail } from "./gateway.js";
|
||||||
|
|
||||||
type ConfigIssueLike = { path: string; message: string };
|
type ConfigIssueLike = { path: string; message: string };
|
||||||
@@ -106,7 +106,7 @@ export async function appendStatusAllDiagnosis(params: {
|
|||||||
if (params.sentinel?.payload) {
|
if (params.sentinel?.payload) {
|
||||||
emitCheck("Restart sentinel present", "warn");
|
emitCheck("Restart sentinel present", "warn");
|
||||||
lines.push(
|
lines.push(
|
||||||
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatAge(Date.now() - params.sentinel.payload.ts)}`)}`,
|
` ${muted(`${summarizeRestartSentinel(params.sentinel.payload)} · ${formatTimeAgo(Date.now() - params.sentinel.payload.ts)}`)}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
emitCheck("Restart sentinel: none", "ok");
|
emitCheck("Restart sentinel: none", "ok");
|
||||||
|
|||||||
@@ -1,31 +1,5 @@
|
|||||||
export const formatAge = (ms: number | null | undefined) => {
|
export { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||||
if (!ms || ms < 0) {
|
export { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(ms / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDuration = (ms: number | null | undefined) => {
|
|
||||||
if (ms == null || !Number.isFinite(ms)) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${Math.round(ms)}ms`;
|
|
||||||
}
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatGatewayAuthUsed(
|
export function formatGatewayAuthUsed(
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ProgressReporter } from "../../cli/progress.js";
|
|||||||
import { renderTable } from "../../terminal/table.js";
|
import { renderTable } from "../../terminal/table.js";
|
||||||
import { isRich, theme } from "../../terminal/theme.js";
|
import { isRich, theme } from "../../terminal/theme.js";
|
||||||
import { appendStatusAllDiagnosis } from "./diagnosis.js";
|
import { appendStatusAllDiagnosis } from "./diagnosis.js";
|
||||||
import { formatAge } from "./format.js";
|
import { formatTimeAgo } from "./format.js";
|
||||||
|
|
||||||
type OverviewRow = { Item: string; Value: string };
|
type OverviewRow = { Item: string; Value: string };
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ export async function buildStatusAllReportLines(params: {
|
|||||||
? ok("OK")
|
? ok("OK")
|
||||||
: "unknown",
|
: "unknown",
|
||||||
Sessions: String(a.sessionsCount),
|
Sessions: String(a.sessionsCount),
|
||||||
Active: a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
|
Active: a.lastActiveAgeMs != null ? formatTimeAgo(a.lastActiveAgeMs) : "unknown",
|
||||||
Store: a.sessionsPath,
|
Store: a.sessionsPath,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { withProgress } from "../cli/progress.js";
|
|||||||
import { resolveGatewayPort } from "../config/config.js";
|
import { resolveGatewayPort } from "../config/config.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
|
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
|
||||||
import {
|
import {
|
||||||
formatUpdateChannelLabel,
|
formatUpdateChannelLabel,
|
||||||
@@ -26,7 +27,6 @@ import { statusAllCommand } from "./status-all.js";
|
|||||||
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
||||||
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||||
import {
|
import {
|
||||||
formatAge,
|
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatKTokens,
|
formatKTokens,
|
||||||
formatTokensCompact,
|
formatTokensCompact,
|
||||||
@@ -239,7 +239,7 @@ export async function statusCommand(
|
|||||||
? `${agentStatus.bootstrapPendingCount} bootstrapping`
|
? `${agentStatus.bootstrapPendingCount} bootstrapping`
|
||||||
: "no bootstraps";
|
: "no bootstraps";
|
||||||
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
|
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
|
||||||
const defActive = def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
|
const defActive = def?.lastActiveAgeMs != null ? formatTimeAgo(def.lastActiveAgeMs) : "unknown";
|
||||||
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
|
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
|
||||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||||
})();
|
})();
|
||||||
@@ -294,7 +294,7 @@ export async function statusCommand(
|
|||||||
if (!lastHeartbeat) {
|
if (!lastHeartbeat) {
|
||||||
return muted("none");
|
return muted("none");
|
||||||
}
|
}
|
||||||
const age = formatAge(Date.now() - lastHeartbeat.ts);
|
const age = formatTimeAgo(Date.now() - lastHeartbeat.ts);
|
||||||
const channel = lastHeartbeat.channel ?? "unknown";
|
const channel = lastHeartbeat.channel ?? "unknown";
|
||||||
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
|
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
|
||||||
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
|
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
|
||||||
@@ -527,7 +527,7 @@ export async function statusCommand(
|
|||||||
? summary.sessions.recent.map((sess) => ({
|
? summary.sessions.recent.map((sess) => ({
|
||||||
Key: shortenText(sess.key, 32),
|
Key: shortenText(sess.key, 32),
|
||||||
Kind: sess.kind,
|
Kind: sess.kind,
|
||||||
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
|
Age: sess.updatedAt ? formatTimeAgo(sess.age) : "no activity",
|
||||||
Model: sess.model ?? "unknown",
|
Model: sess.model ?? "unknown",
|
||||||
Tokens: formatTokensCompact(sess),
|
Tokens: formatTokensCompact(sess),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,35 +1,14 @@
|
|||||||
import type { SessionStatus } from "./status.types.js";
|
import type { SessionStatus } from "./status.types.js";
|
||||||
|
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
|
||||||
|
|
||||||
export const formatKTokens = (value: number) =>
|
export const formatKTokens = (value: number) =>
|
||||||
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||||
|
|
||||||
export const formatAge = (ms: number | null | undefined) => {
|
|
||||||
if (!ms || ms < 0) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(ms / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDuration = (ms: number | null | undefined) => {
|
export const formatDuration = (ms: number | null | undefined) => {
|
||||||
if (ms == null || !Number.isFinite(ms)) {
|
if (ms == null || !Number.isFinite(ms)) {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
if (ms < 1000) {
|
return formatDurationPrecise(ms, { decimals: 1 });
|
||||||
return `${Math.round(ms)}ms`;
|
|
||||||
}
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shortenText = (value: string, maxLen: number) => {
|
export const shortenText = (value: string, maxLen: number) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
PresenceUpdateListener,
|
PresenceUpdateListener,
|
||||||
} from "@buape/carbon";
|
} from "@buape/carbon";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { formatDurationSeconds } from "../../infra/format-duration.js";
|
import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
|
||||||
import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js";
|
import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js";
|
||||||
import { installProcessWarningFilter } from "./infra/warnings.js";
|
import { installProcessWarningFilter } from "./infra/warning-filter.js";
|
||||||
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
|
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
|
||||||
|
|
||||||
process.title = "openclaw";
|
process.title = "openclaw";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
||||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||||
|
|
||||||
describe("injectTimestamp", () => {
|
describe("injectTimestamp", () => {
|
||||||
@@ -23,7 +23,7 @@ describe("injectTimestamp", () => {
|
|||||||
|
|
||||||
it("uses channel envelope format with DOW prefix", () => {
|
it("uses channel envelope format with DOW prefix", () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expected = formatZonedTimestamp(now, "America/New_York");
|
const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" });
|
||||||
|
|
||||||
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OpenClawConfig } from "../../config/types.js";
|
import type { OpenClawConfig } from "../../config/types.js";
|
||||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||||
import { formatZonedTimestamp } from "../../auto-reply/envelope.js";
|
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron jobs inject "Current time: ..." into their messages.
|
* Cron jobs inject "Current time: ..." into their messages.
|
||||||
@@ -56,7 +56,7 @@ export function injectTimestamp(message: string, opts?: TimestampInjectionOption
|
|||||||
const now = opts?.now ?? new Date();
|
const now = opts?.now ?? new Date();
|
||||||
const timezone = opts?.timezone ?? "UTC";
|
const timezone = opts?.timezone ?? "UTC";
|
||||||
|
|
||||||
const formatted = formatZonedTimestamp(now, timezone);
|
const formatted = formatZonedTimestamp(now, { timeZone: timezone });
|
||||||
if (!formatted) {
|
if (!formatted) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
|
|||||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
import { formatTimeAgo } from "./format-time/format-relative.ts";
|
||||||
|
|
||||||
export type ChannelSummaryOptions = {
|
export type ChannelSummaryOptions = {
|
||||||
colorize?: boolean;
|
colorize?: boolean;
|
||||||
@@ -224,7 +225,7 @@ export async function buildChannelSummary(
|
|||||||
line += ` ${self.e164}`;
|
line += ` ${self.e164}`;
|
||||||
}
|
}
|
||||||
if (authAgeMs != null && authAgeMs >= 0) {
|
if (authAgeMs != null && authAgeMs >= 0) {
|
||||||
line += ` auth ${formatAge(authAgeMs)}`;
|
line += ` auth ${formatTimeAgo(authAgeMs)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(tint(line, statusColor));
|
lines.push(tint(line, statusColor));
|
||||||
@@ -252,22 +253,3 @@ export async function buildChannelSummary(
|
|||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAge(ms: number): string {
|
|
||||||
if (ms < 0) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const minutes = Math.round(ms / 60_000);
|
|
||||||
if (minutes < 1) {
|
|
||||||
return "just now";
|
|
||||||
}
|
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
const hours = Math.round(minutes / 60);
|
|
||||||
if (hours < 48) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
const days = Math.round(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
export type FormatDurationSecondsOptions = {
|
|
||||||
decimals?: number;
|
|
||||||
unit?: "s" | "seconds";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatDurationSeconds(
|
|
||||||
ms: number,
|
|
||||||
options: FormatDurationSecondsOptions = {},
|
|
||||||
): string {
|
|
||||||
if (!Number.isFinite(ms)) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const decimals = options.decimals ?? 1;
|
|
||||||
const unit = options.unit ?? "s";
|
|
||||||
const seconds = Math.max(0, ms) / 1000;
|
|
||||||
const fixed = seconds.toFixed(Math.max(0, decimals));
|
|
||||||
const trimmed = fixed.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
|
||||||
return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FormatDurationMsOptions = {
|
|
||||||
decimals?: number;
|
|
||||||
unit?: "s" | "seconds";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatDurationMs(ms: number, options: FormatDurationMsOptions = {}): string {
|
|
||||||
if (!Number.isFinite(ms)) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${ms}ms`;
|
|
||||||
}
|
|
||||||
return formatDurationSeconds(ms, {
|
|
||||||
decimals: options.decimals ?? 2,
|
|
||||||
unit: options.unit ?? "s",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
94
src/infra/format-time/format-datetime.ts
Normal file
94
src/infra/format-time/format-datetime.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Centralized date/time formatting utilities.
|
||||||
|
*
|
||||||
|
* All formatters are timezone-aware, using Intl.DateTimeFormat.
|
||||||
|
* Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone
|
||||||
|
* that previously lived in envelope.ts and session-updates.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an IANA timezone string. Returns the string if valid, undefined otherwise.
|
||||||
|
*/
|
||||||
|
export function resolveTimezone(value: string): string | undefined {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
||||||
|
return value;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormatTimestampOptions = {
|
||||||
|
/** Include seconds in the output. Default: false */
|
||||||
|
displaySeconds?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatZonedTimestampOptions = FormatTimestampOptions & {
|
||||||
|
/** IANA timezone string (e.g., 'America/New_York'). Default: system timezone */
|
||||||
|
timeZone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date as a UTC timestamp string.
|
||||||
|
*
|
||||||
|
* Without seconds: `2024-01-15T14:30Z`
|
||||||
|
* With seconds: `2024-01-15T14:30:05Z`
|
||||||
|
*/
|
||||||
|
export function formatUtcTimestamp(date: Date, options?: FormatTimestampOptions): string {
|
||||||
|
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||||
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||||
|
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||||
|
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
||||||
|
if (!options?.displaySeconds) {
|
||||||
|
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
||||||
|
}
|
||||||
|
const sec = String(date.getUTCSeconds()).padStart(2, "0");
|
||||||
|
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date with timezone display using Intl.DateTimeFormat.
|
||||||
|
*
|
||||||
|
* Without seconds: `2024-01-15 14:30 EST`
|
||||||
|
* With seconds: `2024-01-15 14:30:05 EST`
|
||||||
|
*
|
||||||
|
* Returns undefined if Intl formatting fails.
|
||||||
|
*/
|
||||||
|
export function formatZonedTimestamp(
|
||||||
|
date: Date,
|
||||||
|
options?: FormatZonedTimestampOptions,
|
||||||
|
): string | undefined {
|
||||||
|
const intlOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: options?.timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
timeZoneName: "short",
|
||||||
|
};
|
||||||
|
if (options?.displaySeconds) {
|
||||||
|
intlOptions.second = "2-digit";
|
||||||
|
}
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
|
||||||
|
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
|
const yyyy = pick("year");
|
||||||
|
const mm = pick("month");
|
||||||
|
const dd = pick("day");
|
||||||
|
const hh = pick("hour");
|
||||||
|
const min = pick("minute");
|
||||||
|
const sec = options?.displaySeconds ? pick("second") : undefined;
|
||||||
|
const tz = [...parts]
|
||||||
|
.toReversed()
|
||||||
|
.find((part) => part.type === "timeZoneName")
|
||||||
|
?.value?.trim();
|
||||||
|
if (!yyyy || !mm || !dd || !hh || !min) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (options?.displaySeconds && sec) {
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
||||||
|
}
|
||||||
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
||||||
|
}
|
||||||
103
src/infra/format-time/format-duration.ts
Normal file
103
src/infra/format-time/format-duration.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export type FormatDurationSecondsOptions = {
|
||||||
|
decimals?: number;
|
||||||
|
unit?: "s" | "seconds";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatDurationCompactOptions = {
|
||||||
|
/** Add space between units: "2m 5s" instead of "2m5s". Default: false */
|
||||||
|
spaced?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatDurationSeconds(
|
||||||
|
ms: number,
|
||||||
|
options: FormatDurationSecondsOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!Number.isFinite(ms)) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const decimals = options.decimals ?? 1;
|
||||||
|
const unit = options.unit ?? "s";
|
||||||
|
const seconds = Math.max(0, ms) / 1000;
|
||||||
|
const fixed = seconds.toFixed(Math.max(0, decimals));
|
||||||
|
const trimmed = fixed.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
||||||
|
return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Precise decimal-seconds output: "500ms" or "1.23s". Input is milliseconds. */
|
||||||
|
export function formatDurationPrecise(
|
||||||
|
ms: number,
|
||||||
|
options: FormatDurationSecondsOptions = {},
|
||||||
|
): string {
|
||||||
|
if (!Number.isFinite(ms)) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms}ms`;
|
||||||
|
}
|
||||||
|
return formatDurationSeconds(ms, {
|
||||||
|
decimals: options.decimals ?? 2,
|
||||||
|
unit: options.unit ?? "s",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact compound duration: "500ms", "45s", "2m5s", "1h30m".
|
||||||
|
* With `spaced`: "45s", "2m 5s", "1h 30m".
|
||||||
|
* Omits trailing zero components: "1m" not "1m 0s", "2h" not "2h 0m".
|
||||||
|
* Returns undefined for null/undefined/non-finite/non-positive input.
|
||||||
|
*/
|
||||||
|
export function formatDurationCompact(
|
||||||
|
ms?: number | null,
|
||||||
|
options?: FormatDurationCompactOptions,
|
||||||
|
): string | undefined {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${Math.round(ms)}ms`;
|
||||||
|
}
|
||||||
|
const sep = options?.spaced ? " " : "";
|
||||||
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
return remainingHours > 0 ? `${days}d${sep}${remainingHours}h` : `${days}d`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return minutes > 0 ? `${hours}h${sep}${minutes}m` : `${hours}h`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return seconds > 0 ? `${minutes}m${sep}${seconds}s` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounded single-unit duration for display: "500ms", "5s", "3m", "2h", "5d".
|
||||||
|
* Returns fallback string for null/undefined/non-finite input.
|
||||||
|
*/
|
||||||
|
export function formatDurationHuman(ms?: number | null, fallback = "n/a"): string {
|
||||||
|
if (ms == null || !Number.isFinite(ms) || ms < 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${Math.round(ms)}ms`;
|
||||||
|
}
|
||||||
|
const sec = Math.round(ms / 1000);
|
||||||
|
if (sec < 60) {
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) {
|
||||||
|
return `${min}m`;
|
||||||
|
}
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) {
|
||||||
|
return `${hr}h`;
|
||||||
|
}
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
return `${day}d`;
|
||||||
|
}
|
||||||
112
src/infra/format-time/format-relative.ts
Normal file
112
src/infra/format-time/format-relative.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Centralized relative-time formatting utilities.
|
||||||
|
*
|
||||||
|
* Consolidates 7+ scattered implementations (formatAge, formatAgeShort, formatAgo,
|
||||||
|
* formatRelativeTime, formatElapsedTime) into two functions:
|
||||||
|
*
|
||||||
|
* - `formatTimeAgo(durationMs)` — format a duration as "5m ago" / "5m" (for known elapsed time)
|
||||||
|
* - `formatRelativeTimestamp(epochMs)` — format an epoch timestamp relative to now (handles future)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FormatTimeAgoOptions = {
|
||||||
|
/** Append "ago" suffix. Default: true. When false, returns bare unit: "5m", "2h" */
|
||||||
|
suffix?: boolean;
|
||||||
|
/** Return value for invalid/null/negative input. Default: "unknown" */
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration (in ms) as a human-readable relative time.
|
||||||
|
*
|
||||||
|
* Input: how many milliseconds ago something happened.
|
||||||
|
*
|
||||||
|
* With suffix (default): "just now", "5m ago", "3h ago", "2d ago"
|
||||||
|
* Without suffix: "0s", "5m", "3h", "2d"
|
||||||
|
*/
|
||||||
|
export function formatTimeAgo(
|
||||||
|
durationMs: number | null | undefined,
|
||||||
|
options?: FormatTimeAgoOptions,
|
||||||
|
): string {
|
||||||
|
const suffix = options?.suffix !== false;
|
||||||
|
const fallback = options?.fallback ?? "unknown";
|
||||||
|
|
||||||
|
if (durationMs == null || !Number.isFinite(durationMs) || durationMs < 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.round(durationMs / 1000);
|
||||||
|
const minutes = Math.round(totalSeconds / 60);
|
||||||
|
|
||||||
|
if (minutes < 1) {
|
||||||
|
return suffix ? "just now" : `${totalSeconds}s`;
|
||||||
|
}
|
||||||
|
if (minutes < 60) {
|
||||||
|
return suffix ? `${minutes}m ago` : `${minutes}m`;
|
||||||
|
}
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 48) {
|
||||||
|
return suffix ? `${hours}h ago` : `${hours}h`;
|
||||||
|
}
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return suffix ? `${days}d ago` : `${days}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormatRelativeTimestampOptions = {
|
||||||
|
/** If true, fall back to short date (e.g. "Oct 5") for timestamps >7 days. Default: false */
|
||||||
|
dateFallback?: boolean;
|
||||||
|
/** IANA timezone for date fallback display */
|
||||||
|
timezone?: string;
|
||||||
|
/** Return value for invalid/null input. Default: "n/a" */
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an epoch timestamp relative to now.
|
||||||
|
*
|
||||||
|
* Handles both past ("5m ago") and future ("in 5m") timestamps.
|
||||||
|
* Optionally falls back to a short date for timestamps older than 7 days.
|
||||||
|
*/
|
||||||
|
export function formatRelativeTimestamp(
|
||||||
|
timestampMs: number | null | undefined,
|
||||||
|
options?: FormatRelativeTimestampOptions,
|
||||||
|
): string {
|
||||||
|
const fallback = options?.fallback ?? "n/a";
|
||||||
|
if (timestampMs == null || !Number.isFinite(timestampMs)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = Date.now() - timestampMs;
|
||||||
|
const absDiff = Math.abs(diff);
|
||||||
|
const isPast = diff >= 0;
|
||||||
|
|
||||||
|
const sec = Math.round(absDiff / 1000);
|
||||||
|
if (sec < 60) {
|
||||||
|
return isPast ? "just now" : "in <1m";
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) {
|
||||||
|
return isPast ? `${min}m ago` : `in ${min}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 48) {
|
||||||
|
return isPast ? `${hr}h ago` : `in ${hr}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (!options?.dateFallback || day <= 7) {
|
||||||
|
return isPast ? `${day}d ago` : `in ${day}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to short date display for old timestamps
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...(options.timezone ? { timeZone: options.timezone } : {}),
|
||||||
|
}).format(new Date(timestampMs));
|
||||||
|
} catch {
|
||||||
|
return `${day}d ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/infra/format-time/format-time.test.ts
Normal file
221
src/infra/format-time/format-time.test.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js";
|
||||||
|
import {
|
||||||
|
formatDurationCompact,
|
||||||
|
formatDurationHuman,
|
||||||
|
formatDurationPrecise,
|
||||||
|
formatDurationSeconds,
|
||||||
|
} from "./format-duration.js";
|
||||||
|
import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
|
||||||
|
|
||||||
|
describe("format-duration", () => {
|
||||||
|
describe("formatDurationCompact", () => {
|
||||||
|
it("returns undefined for null/undefined/non-positive", () => {
|
||||||
|
expect(formatDurationCompact(null)).toBeUndefined();
|
||||||
|
expect(formatDurationCompact(undefined)).toBeUndefined();
|
||||||
|
expect(formatDurationCompact(0)).toBeUndefined();
|
||||||
|
expect(formatDurationCompact(-100)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats milliseconds for sub-second durations", () => {
|
||||||
|
expect(formatDurationCompact(500)).toBe("500ms");
|
||||||
|
expect(formatDurationCompact(999)).toBe("999ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats seconds", () => {
|
||||||
|
expect(formatDurationCompact(1000)).toBe("1s");
|
||||||
|
expect(formatDurationCompact(45000)).toBe("45s");
|
||||||
|
expect(formatDurationCompact(59000)).toBe("59s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats minutes and seconds", () => {
|
||||||
|
expect(formatDurationCompact(60000)).toBe("1m");
|
||||||
|
expect(formatDurationCompact(65000)).toBe("1m5s");
|
||||||
|
expect(formatDurationCompact(90000)).toBe("1m30s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits trailing zero components", () => {
|
||||||
|
expect(formatDurationCompact(60000)).toBe("1m"); // not "1m0s"
|
||||||
|
expect(formatDurationCompact(3600000)).toBe("1h"); // not "1h0m"
|
||||||
|
expect(formatDurationCompact(86400000)).toBe("1d"); // not "1d0h"
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats hours and minutes", () => {
|
||||||
|
expect(formatDurationCompact(3660000)).toBe("1h1m");
|
||||||
|
expect(formatDurationCompact(5400000)).toBe("1h30m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats days and hours", () => {
|
||||||
|
expect(formatDurationCompact(90000000)).toBe("1d1h");
|
||||||
|
expect(formatDurationCompact(172800000)).toBe("2d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports spaced option", () => {
|
||||||
|
expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s");
|
||||||
|
expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m");
|
||||||
|
expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds at boundaries", () => {
|
||||||
|
// 59.5 seconds rounds to 60s = 1m
|
||||||
|
expect(formatDurationCompact(59500)).toBe("1m");
|
||||||
|
// 59.4 seconds rounds to 59s
|
||||||
|
expect(formatDurationCompact(59400)).toBe("59s");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDurationHuman", () => {
|
||||||
|
it("returns fallback for invalid input", () => {
|
||||||
|
expect(formatDurationHuman(null)).toBe("n/a");
|
||||||
|
expect(formatDurationHuman(undefined)).toBe("n/a");
|
||||||
|
expect(formatDurationHuman(-100)).toBe("n/a");
|
||||||
|
expect(formatDurationHuman(null, "unknown")).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats single unit", () => {
|
||||||
|
expect(formatDurationHuman(500)).toBe("500ms");
|
||||||
|
expect(formatDurationHuman(5000)).toBe("5s");
|
||||||
|
expect(formatDurationHuman(180000)).toBe("3m");
|
||||||
|
expect(formatDurationHuman(7200000)).toBe("2h");
|
||||||
|
expect(formatDurationHuman(172800000)).toBe("2d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 24h threshold for days", () => {
|
||||||
|
expect(formatDurationHuman(23 * 3600000)).toBe("23h");
|
||||||
|
expect(formatDurationHuman(24 * 3600000)).toBe("1d");
|
||||||
|
expect(formatDurationHuman(25 * 3600000)).toBe("1d"); // rounds
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDurationPrecise", () => {
|
||||||
|
it("shows milliseconds for sub-second", () => {
|
||||||
|
expect(formatDurationPrecise(500)).toBe("500ms");
|
||||||
|
expect(formatDurationPrecise(999)).toBe("999ms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows decimal seconds for >=1s", () => {
|
||||||
|
expect(formatDurationPrecise(1000)).toBe("1s");
|
||||||
|
expect(formatDurationPrecise(1500)).toBe("1.5s");
|
||||||
|
expect(formatDurationPrecise(1234)).toBe("1.23s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unknown for non-finite", () => {
|
||||||
|
expect(formatDurationPrecise(NaN)).toBe("unknown");
|
||||||
|
expect(formatDurationPrecise(Infinity)).toBe("unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDurationSeconds", () => {
|
||||||
|
it("formats with configurable decimals", () => {
|
||||||
|
expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s");
|
||||||
|
expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s");
|
||||||
|
expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports seconds unit", () => {
|
||||||
|
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("format-datetime", () => {
|
||||||
|
describe("resolveTimezone", () => {
|
||||||
|
it("returns valid IANA timezone strings", () => {
|
||||||
|
expect(resolveTimezone("America/New_York")).toBe("America/New_York");
|
||||||
|
expect(resolveTimezone("Europe/London")).toBe("Europe/London");
|
||||||
|
expect(resolveTimezone("UTC")).toBe("UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for invalid timezones", () => {
|
||||||
|
expect(resolveTimezone("Invalid/Timezone")).toBeUndefined();
|
||||||
|
expect(resolveTimezone("garbage")).toBeUndefined();
|
||||||
|
expect(resolveTimezone("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatUtcTimestamp", () => {
|
||||||
|
it("formats without seconds by default", () => {
|
||||||
|
const date = new Date("2024-01-15T14:30:45.000Z");
|
||||||
|
expect(formatUtcTimestamp(date)).toBe("2024-01-15T14:30Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes seconds when requested", () => {
|
||||||
|
const date = new Date("2024-01-15T14:30:45.000Z");
|
||||||
|
expect(formatUtcTimestamp(date, { displaySeconds: true })).toBe("2024-01-15T14:30:45Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatZonedTimestamp", () => {
|
||||||
|
it("formats with timezone abbreviation", () => {
|
||||||
|
const date = new Date("2024-01-15T14:30:00.000Z");
|
||||||
|
const result = formatZonedTimestamp(date, { timeZone: "UTC" });
|
||||||
|
expect(result).toMatch(/2024-01-15 14:30/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes seconds when requested", () => {
|
||||||
|
const date = new Date("2024-01-15T14:30:45.000Z");
|
||||||
|
const result = formatZonedTimestamp(date, { timeZone: "UTC", displaySeconds: true });
|
||||||
|
expect(result).toMatch(/2024-01-15 14:30:45/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("format-relative", () => {
|
||||||
|
describe("formatTimeAgo", () => {
|
||||||
|
it("returns fallback for invalid input", () => {
|
||||||
|
expect(formatTimeAgo(null)).toBe("unknown");
|
||||||
|
expect(formatTimeAgo(undefined)).toBe("unknown");
|
||||||
|
expect(formatTimeAgo(-100)).toBe("unknown");
|
||||||
|
expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats with 'ago' suffix by default", () => {
|
||||||
|
expect(formatTimeAgo(0)).toBe("just now");
|
||||||
|
expect(formatTimeAgo(29000)).toBe("just now"); // rounds to <1m
|
||||||
|
expect(formatTimeAgo(30000)).toBe("1m ago"); // 30s rounds to 1m
|
||||||
|
expect(formatTimeAgo(300000)).toBe("5m ago");
|
||||||
|
expect(formatTimeAgo(7200000)).toBe("2h ago");
|
||||||
|
expect(formatTimeAgo(172800000)).toBe("2d ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits suffix when suffix: false", () => {
|
||||||
|
expect(formatTimeAgo(0, { suffix: false })).toBe("0s");
|
||||||
|
expect(formatTimeAgo(300000, { suffix: false })).toBe("5m");
|
||||||
|
expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses 48h threshold before switching to days", () => {
|
||||||
|
expect(formatTimeAgo(47 * 3600000)).toBe("47h ago");
|
||||||
|
expect(formatTimeAgo(48 * 3600000)).toBe("2d ago");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelativeTimestamp", () => {
|
||||||
|
it("returns fallback for invalid input", () => {
|
||||||
|
expect(formatRelativeTimestamp(null)).toBe("n/a");
|
||||||
|
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
|
||||||
|
expect(formatRelativeTimestamp(null, { fallback: "unknown" })).toBe("unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats past timestamps", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatRelativeTimestamp(now - 10000)).toBe("just now");
|
||||||
|
expect(formatRelativeTimestamp(now - 300000)).toBe("5m ago");
|
||||||
|
expect(formatRelativeTimestamp(now - 7200000)).toBe("2h ago");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats future timestamps", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
expect(formatRelativeTimestamp(now + 30000)).toBe("in <1m");
|
||||||
|
expect(formatRelativeTimestamp(now + 300000)).toBe("in 5m");
|
||||||
|
expect(formatRelativeTimestamp(now + 7200000)).toBe("in 2h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to date for old timestamps when enabled", () => {
|
||||||
|
const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago
|
||||||
|
const result = formatRelativeTimestamp(oldDate, { dateFallback: true });
|
||||||
|
// Should be a short date like "Jan 9" not "30d ago"
|
||||||
|
expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { installProcessWarningFilter } from "./warning-filter.js";
|
|
||||||
@@ -59,7 +59,9 @@ function getVoyageHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitVoyageBatchRequests(requests: VoyageBatchRequest[]): VoyageBatchRequest[][] {
|
function splitVoyageBatchRequests(requests: VoyageBatchRequest[]): VoyageBatchRequest[][] {
|
||||||
if (requests.length <= VOYAGE_BATCH_MAX_REQUESTS) return [requests];
|
if (requests.length <= VOYAGE_BATCH_MAX_REQUESTS) {
|
||||||
|
return [requests];
|
||||||
|
}
|
||||||
const groups: VoyageBatchRequest[][] = [];
|
const groups: VoyageBatchRequest[][] = [];
|
||||||
for (let i = 0; i < requests.length; i += VOYAGE_BATCH_MAX_REQUESTS) {
|
for (let i = 0; i < requests.length; i += VOYAGE_BATCH_MAX_REQUESTS) {
|
||||||
groups.push(requests.slice(i, i + VOYAGE_BATCH_MAX_REQUESTS));
|
groups.push(requests.slice(i, i + VOYAGE_BATCH_MAX_REQUESTS));
|
||||||
@@ -170,7 +172,9 @@ async function readVoyageBatchError(params: {
|
|||||||
throw new Error(`voyage batch error file content failed: ${res.status} ${text}`);
|
throw new Error(`voyage batch error file content failed: ${res.status} ${text}`);
|
||||||
}
|
}
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!text.trim()) return undefined;
|
if (!text.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const lines = text
|
const lines = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
@@ -246,7 +250,9 @@ export async function runVoyageEmbeddingBatches(params: {
|
|||||||
concurrency: number;
|
concurrency: number;
|
||||||
debug?: (message: string, data?: Record<string, unknown>) => void;
|
debug?: (message: string, data?: Record<string, unknown>) => void;
|
||||||
}): Promise<Map<string, number[]>> {
|
}): Promise<Map<string, number[]>> {
|
||||||
if (params.requests.length === 0) return new Map();
|
if (params.requests.length === 0) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
const groups = splitVoyageBatchRequests(params.requests);
|
const groups = splitVoyageBatchRequests(params.requests);
|
||||||
const byCustomId = new Map<string, number[]>();
|
const byCustomId = new Map<string, number[]>();
|
||||||
|
|
||||||
@@ -307,15 +313,19 @@ export async function runVoyageEmbeddingBatches(params: {
|
|||||||
|
|
||||||
if (contentRes.body) {
|
if (contentRes.body) {
|
||||||
const reader = createInterface({
|
const reader = createInterface({
|
||||||
input: Readable.fromWeb(contentRes.body as any),
|
input: Readable.fromWeb(contentRes.body as unknown as import("stream/web").ReadableStream),
|
||||||
terminal: false,
|
terminal: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const rawLine of reader) {
|
for await (const rawLine of reader) {
|
||||||
if (!rawLine.trim()) continue;
|
if (!rawLine.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const line = JSON.parse(rawLine) as VoyageBatchOutputLine;
|
const line = JSON.parse(rawLine) as VoyageBatchOutputLine;
|
||||||
const customId = line.custom_id;
|
const customId = line.custom_id;
|
||||||
if (!customId) continue;
|
if (!customId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
remaining.delete(customId);
|
remaining.delete(customId);
|
||||||
if (line.error?.message) {
|
if (line.error?.message) {
|
||||||
errors.push(`${customId}: ${line.error.message}`);
|
errors.push(`${customId}: ${line.error.message}`);
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
vi.mock("../agents/model-auth.js", () => ({
|
vi.mock("../agents/model-auth.js", () => ({
|
||||||
resolveApiKeyForProvider: vi.fn(),
|
resolveApiKeyForProvider: vi.fn(),
|
||||||
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
requireApiKey: (auth: { apiKey?: string; mode?: string }, provider: string) => {
|
||||||
if (auth?.apiKey) return auth.apiKey;
|
if (auth?.apiKey) {
|
||||||
|
return auth.apiKey;
|
||||||
|
}
|
||||||
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`);
|
throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth?.mode}).`);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ const DEFAULT_VOYAGE_BASE_URL = "https://api.voyageai.com/v1";
|
|||||||
|
|
||||||
export function normalizeVoyageModel(model: string): string {
|
export function normalizeVoyageModel(model: string): string {
|
||||||
const trimmed = model.trim();
|
const trimmed = model.trim();
|
||||||
if (!trimmed) return DEFAULT_VOYAGE_EMBEDDING_MODEL;
|
if (!trimmed) {
|
||||||
if (trimmed.startsWith("voyage/")) return trimmed.slice("voyage/".length);
|
return DEFAULT_VOYAGE_EMBEDDING_MODEL;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("voyage/")) {
|
||||||
|
return trimmed.slice("voyage/".length);
|
||||||
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +28,16 @@ export async function createVoyageEmbeddingProvider(
|
|||||||
const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`;
|
const url = `${client.baseUrl.replace(/\/$/, "")}/embeddings`;
|
||||||
|
|
||||||
const embed = async (input: string[], input_type?: "query" | "document"): Promise<number[][]> => {
|
const embed = async (input: string[], input_type?: "query" | "document"): Promise<number[][]> => {
|
||||||
if (input.length === 0) return [];
|
if (input.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const body: { model: string; input: string[]; input_type?: "query" | "document" } = {
|
const body: { model: string; input: string[]; input_type?: "query" | "document" } = {
|
||||||
model: client.model,
|
model: client.model,
|
||||||
input,
|
input,
|
||||||
};
|
};
|
||||||
if (input_type) body.input_type = input_type;
|
if (input_type) {
|
||||||
|
body.input_type = input_type;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -280,7 +280,9 @@ export async function runWithConcurrency<T>(
|
|||||||
tasks: Array<() => Promise<T>>,
|
tasks: Array<() => Promise<T>>,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
if (tasks.length === 0) return [];
|
if (tasks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
|
const resolvedLimit = Math.max(1, Math.min(limit, tasks.length));
|
||||||
const results: T[] = Array.from({ length: tasks.length });
|
const results: T[] = Array.from({ length: tasks.length });
|
||||||
let next = 0;
|
let next = 0;
|
||||||
@@ -288,10 +290,14 @@ export async function runWithConcurrency<T>(
|
|||||||
|
|
||||||
const workers = Array.from({ length: resolvedLimit }, async () => {
|
const workers = Array.from({ length: resolvedLimit }, async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (firstError) return;
|
if (firstError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const index = next;
|
const index = next;
|
||||||
next += 1;
|
next += 1;
|
||||||
if (index >= tasks.length) return;
|
if (index >= tasks.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
results[index] = await tasks[index]();
|
results[index] = await tasks[index]();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -302,6 +308,8 @@ export async function runWithConcurrency<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.allSettled(workers);
|
await Promise.allSettled(workers);
|
||||||
if (firstError) throw firstError;
|
if (firstError) {
|
||||||
|
throw firstError;
|
||||||
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1890,7 +1890,9 @@ export class MemoryIndexManager implements MemorySearchManager {
|
|||||||
if (!voyage) {
|
if (!voyage) {
|
||||||
return this.embedChunksInBatches(chunks);
|
return this.embedChunksInBatches(chunks);
|
||||||
}
|
}
|
||||||
if (chunks.length === 0) return [];
|
if (chunks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const cached = this.loadEmbeddingCache(chunks.map((chunk) => chunk.hash));
|
const cached = this.loadEmbeddingCache(chunks.map((chunk) => chunk.hash));
|
||||||
const embeddings: number[][] = Array.from({ length: chunks.length }, () => []);
|
const embeddings: number[][] = Array.from({ length: chunks.length }, () => []);
|
||||||
const missing: Array<{ index: number; chunk: MemoryChunk }> = [];
|
const missing: Array<{ index: number; chunk: MemoryChunk }> = [];
|
||||||
@@ -1905,7 +1907,9 @@ export class MemoryIndexManager implements MemorySearchManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length === 0) return embeddings;
|
if (missing.length === 0) {
|
||||||
|
return embeddings;
|
||||||
|
}
|
||||||
|
|
||||||
const requests: VoyageBatchRequest[] = [];
|
const requests: VoyageBatchRequest[] = [];
|
||||||
const mapping = new Map<string, { index: number; hash: string }>();
|
const mapping = new Map<string, { index: number; hash: string }>();
|
||||||
@@ -1937,13 +1941,17 @@ export class MemoryIndexManager implements MemorySearchManager {
|
|||||||
}),
|
}),
|
||||||
fallback: async () => await this.embedChunksInBatches(chunks),
|
fallback: async () => await this.embedChunksInBatches(chunks),
|
||||||
});
|
});
|
||||||
if (Array.isArray(batchResult)) return batchResult;
|
if (Array.isArray(batchResult)) {
|
||||||
|
return batchResult;
|
||||||
|
}
|
||||||
const byCustomId = batchResult;
|
const byCustomId = batchResult;
|
||||||
|
|
||||||
const toCache: Array<{ hash: string; embedding: number[] }> = [];
|
const toCache: Array<{ hash: string; embedding: number[] }> = [];
|
||||||
for (const [customId, embedding] of byCustomId.entries()) {
|
for (const [customId, embedding] of byCustomId.entries()) {
|
||||||
const mapped = mapping.get(customId);
|
const mapped = mapping.get(customId);
|
||||||
if (!mapped) continue;
|
if (!mapped) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
embeddings[mapped.index] = embedding;
|
embeddings[mapped.index] = embedding;
|
||||||
toCache.push({ hash: mapped.hash, embedding });
|
toCache.push({ hash: mapped.hash, embedding });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { installProcessWarningFilter } from "../infra/warnings.js";
|
import { installProcessWarningFilter } from "../infra/warning-filter.js";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { resolveAgentMaxConcurrent } from "../config/agent-limits.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { formatDurationMs } from "../infra/format-duration.js";
|
import { formatDurationPrecise } from "../infra/format-time/format-duration.ts";
|
||||||
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
|
||||||
@@ -195,7 +195,7 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
|
|||||||
const reason = isConflict ? "getUpdates conflict" : "network error";
|
const reason = isConflict ? "getUpdates conflict" : "network error";
|
||||||
const errMsg = formatErrorMessage(err);
|
const errMsg = formatErrorMessage(err);
|
||||||
(opts.runtime?.error ?? console.error)(
|
(opts.runtime?.error ?? console.error)(
|
||||||
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`,
|
`Telegram ${reason}: ${errMsg}; retrying in ${formatDurationPrecise(delayMs)}.`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await sleepWithAbort(delayMs, opts.abortSignal);
|
await sleepWithAbort(delayMs, opts.abortSignal);
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
normalizeUsageDisplay,
|
normalizeUsageDisplay,
|
||||||
resolveResponseUsageMode,
|
resolveResponseUsageMode,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
|
import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { formatRelativeTime } from "../utils/time-format.js";
|
|
||||||
import { helpText, parseCommand } from "./commands.js";
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
import {
|
import {
|
||||||
createFilterableSelectList,
|
createFilterableSelectList,
|
||||||
@@ -158,7 +158,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
// Avoid redundant "title (key)" when title matches key
|
// Avoid redundant "title (key)" when title matches key
|
||||||
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||||
// Build description: time + message preview
|
// Build description: time + message preview
|
||||||
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
const timePart = session.updatedAt
|
||||||
|
? formatRelativeTimestamp(session.updatedAt, { dateFallback: true, fallback: "" })
|
||||||
|
: "";
|
||||||
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
||||||
const description =
|
const description =
|
||||||
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
|
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GatewayStatusSummary } from "./tui-types.js";
|
import type { GatewayStatusSummary } from "./tui-types.js";
|
||||||
import { formatAge } from "../infra/channel-summary.js";
|
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||||
import { formatTokenCount } from "../utils/usage-format.js";
|
import { formatTokenCount } from "../utils/usage-format.js";
|
||||||
import { formatContextUsageLine } from "./tui-formatters.js";
|
import { formatContextUsageLine } from "./tui-formatters.js";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
|||||||
const linked = summary.linkChannel.linked === true;
|
const linked = summary.linkChannel.linked === true;
|
||||||
const authAge =
|
const authAge =
|
||||||
linked && typeof summary.linkChannel.authAgeMs === "number"
|
linked && typeof summary.linkChannel.authAgeMs === "number"
|
||||||
? ` (last refreshed ${formatAge(summary.linkChannel.authAgeMs)})`
|
? ` (last refreshed ${formatTimeAgo(summary.linkChannel.authAgeMs)})`
|
||||||
: "";
|
: "";
|
||||||
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
lines.push(`${linkLabel}: ${linked ? "linked" : "not linked"}${authAge}`);
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ export function formatStatusSummary(summary: GatewayStatusSummary) {
|
|||||||
if (recent.length > 0) {
|
if (recent.length > 0) {
|
||||||
lines.push("Recent sessions:");
|
lines.push("Recent sessions:");
|
||||||
for (const entry of recent) {
|
for (const entry of recent) {
|
||||||
const ageLabel = typeof entry.age === "number" ? formatAge(entry.age) : "no activity";
|
const ageLabel = typeof entry.age === "number" ? formatTimeAgo(entry.age) : "no activity";
|
||||||
const model = entry.model ?? "unknown";
|
const model = entry.model ?? "unknown";
|
||||||
const usage = formatContextUsageLine({
|
const usage = formatContextUsageLine({
|
||||||
total: entry.totalTokens ?? null,
|
total: entry.totalTokens ?? null,
|
||||||
|
|||||||
@@ -1,25 +1,6 @@
|
|||||||
export function formatRelativeTime(timestamp: number): string {
|
import { formatRelativeTimestamp } from "../infra/format-time/format-relative.ts";
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - timestamp;
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (seconds < 60) {
|
/** Delegates to centralized formatRelativeTimestamp with date fallback for >7d. */
|
||||||
return "just now";
|
export function formatRelativeTime(timestamp: number): string {
|
||||||
}
|
return formatRelativeTimestamp(timestamp, { dateFallback: true, fallback: "unknown" });
|
||||||
if (minutes < 60) {
|
|
||||||
return `${minutes}m ago`;
|
|
||||||
}
|
|
||||||
if (hours < 24) {
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
if (days === 1) {
|
|
||||||
return "Yesterday";
|
|
||||||
}
|
|
||||||
if (days < 7) {
|
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
|
||||||
return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { formatCliCommand } from "../../cli/command-format.js";
|
|||||||
import { waitForever } from "../../cli/wait.js";
|
import { waitForever } from "../../cli/wait.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
import { formatDurationMs } from "../../infra/format-duration.js";
|
import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
|
||||||
import { getChildLogger } from "../../logging.js";
|
import { getChildLogger } from "../../logging.js";
|
||||||
@@ -432,7 +432,7 @@ export async function monitorWebChannel(
|
|||||||
"web reconnect: scheduling retry",
|
"web reconnect: scheduling retry",
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationMs(delay)}… (${errorStr})`,
|
`WhatsApp Web connection closed (status ${statusCode}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(delay)}… (${errorStr})`,
|
||||||
);
|
);
|
||||||
await closeListener();
|
await closeListener();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,53 +1,19 @@
|
|||||||
|
import {
|
||||||
|
formatUtcTimestamp,
|
||||||
|
formatZonedTimestamp,
|
||||||
|
} from "../../src/infra/format-time/format-datetime.js";
|
||||||
|
|
||||||
type EnvelopeTimestampZone = string;
|
type EnvelopeTimestampZone = string;
|
||||||
|
|
||||||
function formatUtcTimestamp(date: Date): string {
|
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
|
||||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
|
||||||
const min = String(date.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatZonedTimestamp(date: Date, timeZone?: string): string {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
timeZoneName: "short",
|
|
||||||
}).formatToParts(date);
|
|
||||||
|
|
||||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
|
||||||
const yyyy = pick("year");
|
|
||||||
const mm = pick("month");
|
|
||||||
const dd = pick("day");
|
|
||||||
const hh = pick("hour");
|
|
||||||
const min = pick("minute");
|
|
||||||
const tz = [...parts]
|
|
||||||
.toReversed()
|
|
||||||
.find((part) => part.type === "timeZoneName")
|
|
||||||
?.value?.trim();
|
|
||||||
|
|
||||||
if (!yyyy || !mm || !dd || !hh || !min) {
|
|
||||||
throw new Error("Missing date parts for envelope timestamp formatting.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
|
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
|
||||||
const normalized = zone.trim().toLowerCase();
|
const normalized = zone.trim().toLowerCase();
|
||||||
if (normalized === "utc" || normalized === "gmt") {
|
if (normalized === "utc" || normalized === "gmt") {
|
||||||
return formatUtcTimestamp(date);
|
return formatUtcTimestamp(date);
|
||||||
}
|
}
|
||||||
if (normalized === "local" || normalized === "host") {
|
if (normalized === "local" || normalized === "host") {
|
||||||
return formatZonedTimestamp(date);
|
return formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
|
||||||
}
|
}
|
||||||
return formatZonedTimestamp(date, zone);
|
return formatZonedTimestamp(date, { timeZone: zone }) ?? formatUtcTimestamp(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLocalEnvelopeTimestamp(date: Date): string {
|
export function formatLocalEnvelopeTimestamp(date: Date): string {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
} from "../src/channels/plugins/types.js";
|
} from "../src/channels/plugins/types.js";
|
||||||
import type { OpenClawConfig } from "../src/config/config.js";
|
import type { OpenClawConfig } from "../src/config/config.js";
|
||||||
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js";
|
||||||
import { installProcessWarningFilter } from "../src/infra/warnings.js";
|
import { installProcessWarningFilter } from "../src/infra/warning-filter.js";
|
||||||
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
import { setActivePluginRegistry } from "../src/plugins/runtime.js";
|
||||||
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
|
import { createTestRegistry } from "../src/test-utils/channel-plugins.js";
|
||||||
import { withIsolatedTestHome } from "./test-env";
|
import { withIsolatedTestHome } from "./test-env";
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatAgo, stripThinkingTags } from "./format.ts";
|
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
|
||||||
|
|
||||||
describe("formatAgo", () => {
|
describe("formatAgo", () => {
|
||||||
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
|
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
|
||||||
expect(formatAgo(Date.now() + 30_000)).toBe("in <1m");
|
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Xm from now' for future timestamps", () => {
|
it("returns 'Xm from now' for future timestamps", () => {
|
||||||
expect(formatAgo(Date.now() + 5 * 60_000)).toBe("5m from now");
|
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("5m from now");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Xh from now' for future timestamps", () => {
|
it("returns 'Xh from now' for future timestamps", () => {
|
||||||
expect(formatAgo(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
|
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("3h from now");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
|
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
|
||||||
expect(formatAgo(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
|
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("3d from now");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Xs ago' for recent past timestamps", () => {
|
it("returns 'Xs ago' for recent past timestamps", () => {
|
||||||
expect(formatAgo(Date.now() - 10_000)).toBe("10s ago");
|
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("10s ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'Xm ago' for past timestamps", () => {
|
it("returns 'Xm ago' for past timestamps", () => {
|
||||||
expect(formatAgo(Date.now() - 5 * 60_000)).toBe("5m ago");
|
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 'n/a' for null/undefined", () => {
|
it("returns 'n/a' for null/undefined", () => {
|
||||||
expect(formatAgo(null)).toBe("n/a");
|
expect(formatRelativeTimestamp(null)).toBe("n/a");
|
||||||
expect(formatAgo(undefined)).toBe("n/a");
|
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { formatDurationHuman } from "../../../src/infra/format-time/format-duration.ts";
|
||||||
|
import { formatRelativeTimestamp } from "../../../src/infra/format-time/format-relative.ts";
|
||||||
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js";
|
||||||
|
|
||||||
|
export { formatRelativeTimestamp, formatDurationHuman };
|
||||||
|
|
||||||
export function formatMs(ms?: number | null): string {
|
export function formatMs(ms?: number | null): string {
|
||||||
if (!ms && ms !== 0) {
|
if (!ms && ms !== 0) {
|
||||||
return "n/a";
|
return "n/a";
|
||||||
@@ -7,52 +11,6 @@ export function formatMs(ms?: number | null): string {
|
|||||||
return new Date(ms).toLocaleString();
|
return new Date(ms).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAgo(ms?: number | null): string {
|
|
||||||
if (!ms && ms !== 0) {
|
|
||||||
return "n/a";
|
|
||||||
}
|
|
||||||
const diff = Date.now() - ms;
|
|
||||||
const absDiff = Math.abs(diff);
|
|
||||||
const suffix = diff < 0 ? "from now" : "ago";
|
|
||||||
const sec = Math.round(absDiff / 1000);
|
|
||||||
if (sec < 60) {
|
|
||||||
return diff < 0 ? "in <1m" : `${sec}s ago`;
|
|
||||||
}
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) {
|
|
||||||
return `${min}m ${suffix}`;
|
|
||||||
}
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
if (hr < 48) {
|
|
||||||
return `${hr}h ${suffix}`;
|
|
||||||
}
|
|
||||||
const day = Math.round(hr / 24);
|
|
||||||
return `${day}d ${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDurationMs(ms?: number | null): string {
|
|
||||||
if (!ms && ms !== 0) {
|
|
||||||
return "n/a";
|
|
||||||
}
|
|
||||||
if (ms < 1000) {
|
|
||||||
return `${ms}ms`;
|
|
||||||
}
|
|
||||||
const sec = Math.round(ms / 1000);
|
|
||||||
if (sec < 60) {
|
|
||||||
return `${sec}s`;
|
|
||||||
}
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) {
|
|
||||||
return `${min}m`;
|
|
||||||
}
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
if (hr < 48) {
|
|
||||||
return `${hr}h`;
|
|
||||||
}
|
|
||||||
const day = Math.round(hr / 24);
|
|
||||||
return `${day}d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatList(values?: Array<string | null | undefined>): string {
|
export function formatList(values?: Array<string | null | undefined>): string {
|
||||||
if (!values || values.length === 0) {
|
if (!values || values.length === 0) {
|
||||||
return "none";
|
return "none";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
|
import type { CronJob, GatewaySessionRow, PresenceEntry } from "./types.ts";
|
||||||
import { formatAgo, formatDurationMs, formatMs } from "./format.ts";
|
import { formatRelativeTimestamp, formatDurationHuman, formatMs } from "./format.ts";
|
||||||
|
|
||||||
export function formatPresenceSummary(entry: PresenceEntry): string {
|
export function formatPresenceSummary(entry: PresenceEntry): string {
|
||||||
const host = entry.host ?? "unknown";
|
const host = entry.host ?? "unknown";
|
||||||
@@ -11,14 +11,14 @@ export function formatPresenceSummary(entry: PresenceEntry): string {
|
|||||||
|
|
||||||
export function formatPresenceAge(entry: PresenceEntry): string {
|
export function formatPresenceAge(entry: PresenceEntry): string {
|
||||||
const ts = entry.ts ?? null;
|
const ts = entry.ts ?? null;
|
||||||
return ts ? formatAgo(ts) : "n/a";
|
return ts ? formatRelativeTimestamp(ts) : "n/a";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNextRun(ms?: number | null) {
|
export function formatNextRun(ms?: number | null) {
|
||||||
if (!ms) {
|
if (!ms) {
|
||||||
return "n/a";
|
return "n/a";
|
||||||
}
|
}
|
||||||
return `${formatMs(ms)} (${formatAgo(ms)})`;
|
return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSessionTokens(row: GatewaySessionRow) {
|
export function formatSessionTokens(row: GatewaySessionRow) {
|
||||||
@@ -57,7 +57,7 @@ export function formatCronSchedule(job: CronJob) {
|
|||||||
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
|
return Number.isFinite(atMs) ? `At ${formatMs(atMs)}` : `At ${s.at}`;
|
||||||
}
|
}
|
||||||
if (s.kind === "every") {
|
if (s.kind === "every") {
|
||||||
return `Every ${formatDurationMs(s.everyMs)}`;
|
return `Every ${formatDurationHuman(s.everyMs)}`;
|
||||||
}
|
}
|
||||||
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
return `Cron ${s.expr}${s.tz ? ` (${s.tz})` : ""}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
normalizeToolName,
|
normalizeToolName,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
} from "../../../../src/agents/tool-policy.js";
|
} from "../../../../src/agents/tool-policy.js";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import {
|
import {
|
||||||
formatCronPayload,
|
formatCronPayload,
|
||||||
formatCronSchedule,
|
formatCronSchedule,
|
||||||
@@ -1112,7 +1112,9 @@ function renderAgentChannels(params: {
|
|||||||
params.agentIdentity,
|
params.agentIdentity,
|
||||||
);
|
);
|
||||||
const entries = resolveChannelEntries(params.snapshot);
|
const entries = resolveChannelEntries(params.snapshot);
|
||||||
const lastSuccessLabel = params.lastSuccess ? formatAgo(params.lastSuccess) : "never";
|
const lastSuccessLabel = params.lastSuccess
|
||||||
|
? formatRelativeTimestamp(params.lastSuccess)
|
||||||
|
: "never";
|
||||||
return html`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid grid-cols-2">
|
||||||
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")}
|
${renderAgentContextCard(context, "Workspace, identity, and model configuration.")}
|
||||||
@@ -1407,7 +1409,7 @@ function renderAgentFiles(params: {
|
|||||||
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
|
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
|
||||||
const status = file.missing
|
const status = file.missing
|
||||||
? "Missing"
|
? "Missing"
|
||||||
: `${formatBytes(file.size)} · ${formatAgo(file.updatedAtMs ?? null)}`;
|
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { DiscordStatus } from "../types.ts";
|
import type { DiscordStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderDiscordCard(params: {
|
export function renderDiscordCard(params: {
|
||||||
@@ -28,11 +28,11 @@ export function renderDiscordCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${discord?.lastStartAt ? formatAgo(discord.lastStartAt) : "n/a"}</span>
|
<span>${discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${discord?.lastProbeAt ? formatAgo(discord.lastProbeAt) : "n/a"}</span>
|
<span>${discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { GoogleChatStatus } from "../types.ts";
|
import type { GoogleChatStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderGoogleChatCard(params: {
|
export function renderGoogleChatCard(params: {
|
||||||
@@ -42,11 +42,11 @@ export function renderGoogleChatCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${googleChat?.lastStartAt ? formatAgo(googleChat.lastStartAt) : "n/a"}</span>
|
<span>${googleChat?.lastStartAt ? formatRelativeTimestamp(googleChat.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${googleChat?.lastProbeAt ? formatAgo(googleChat.lastProbeAt) : "n/a"}</span>
|
<span>${googleChat?.lastProbeAt ? formatRelativeTimestamp(googleChat.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { IMessageStatus } from "../types.ts";
|
import type { IMessageStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderIMessageCard(params: {
|
export function renderIMessageCard(params: {
|
||||||
@@ -28,11 +28,11 @@ export function renderIMessageCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${imessage?.lastStartAt ? formatAgo(imessage.lastStartAt) : "n/a"}</span>
|
<span>${imessage?.lastStartAt ? formatRelativeTimestamp(imessage.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${imessage?.lastProbeAt ? formatAgo(imessage.lastProbeAt) : "n/a"}</span>
|
<span>${imessage?.lastProbeAt ? formatRelativeTimestamp(imessage.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts";
|
import type { ChannelAccountSnapshot, NostrStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
import {
|
import {
|
||||||
renderNostrProfileForm,
|
renderNostrProfileForm,
|
||||||
@@ -79,7 +79,7 @@ export function renderNostrCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last inbound</span>
|
<span class="label">Last inbound</span>
|
||||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${
|
||||||
account.lastError
|
account.lastError
|
||||||
@@ -213,7 +213,7 @@ export function renderNostrCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
|
<span>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -2,22 +2,6 @@ import { html, nothing } from "lit";
|
|||||||
import type { ChannelAccountSnapshot } from "../types.ts";
|
import type { ChannelAccountSnapshot } from "../types.ts";
|
||||||
import type { ChannelKey, ChannelsProps } from "./channels.types.ts";
|
import type { ChannelKey, ChannelsProps } from "./channels.types.ts";
|
||||||
|
|
||||||
export function formatDuration(ms?: number | null) {
|
|
||||||
if (!ms && ms !== 0) {
|
|
||||||
return "n/a";
|
|
||||||
}
|
|
||||||
const sec = Math.round(ms / 1000);
|
|
||||||
if (sec < 60) {
|
|
||||||
return `${sec}s`;
|
|
||||||
}
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) {
|
|
||||||
return `${min}m`;
|
|
||||||
}
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
return `${hr}h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
export function channelEnabled(key: ChannelKey, props: ChannelsProps) {
|
||||||
const snapshot = props.snapshot;
|
const snapshot = props.snapshot;
|
||||||
const channels = snapshot?.channels as Record<string, unknown> | null;
|
const channels = snapshot?.channels as Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { SignalStatus } from "../types.ts";
|
import type { SignalStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderSignalCard(params: {
|
export function renderSignalCard(params: {
|
||||||
@@ -32,11 +32,11 @@ export function renderSignalCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${signal?.lastStartAt ? formatAgo(signal.lastStartAt) : "n/a"}</span>
|
<span>${signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${signal?.lastProbeAt ? formatAgo(signal.lastProbeAt) : "n/a"}</span>
|
<span>${signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { SlackStatus } from "../types.ts";
|
import type { SlackStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderSlackCard(params: {
|
export function renderSlackCard(params: {
|
||||||
@@ -28,11 +28,11 @@ export function renderSlackCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
|
<span>${slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
|
<span>${slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts";
|
import type { ChannelAccountSnapshot, TelegramStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
|
|
||||||
export function renderTelegramCard(params: {
|
export function renderTelegramCard(params: {
|
||||||
@@ -36,7 +36,7 @@ export function renderTelegramCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last inbound</span>
|
<span class="label">Last inbound</span>
|
||||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${
|
||||||
account.lastError
|
account.lastError
|
||||||
@@ -81,11 +81,11 @@ export function renderTelegramCard(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last start</span>
|
<span class="label">Last start</span>
|
||||||
<span>${telegram?.lastStartAt ? formatAgo(telegram.lastStartAt) : "n/a"}</span>
|
<span>${telegram?.lastStartAt ? formatRelativeTimestamp(telegram.lastStartAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last probe</span>
|
<span class="label">Last probe</span>
|
||||||
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
|
<span>${telegram?.lastProbeAt ? formatRelativeTimestamp(telegram.lastProbeAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
WhatsAppStatus,
|
WhatsAppStatus,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts";
|
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
import { renderDiscordCard } from "./channels.discord.ts";
|
import { renderDiscordCard } from "./channels.discord.ts";
|
||||||
import { renderGoogleChatCard } from "./channels.googlechat.ts";
|
import { renderGoogleChatCard } from "./channels.googlechat.ts";
|
||||||
@@ -73,7 +73,7 @@ export function renderChannels(props: ChannelsProps) {
|
|||||||
<div class="card-title">Channel health</div>
|
<div class="card-title">Channel health</div>
|
||||||
<div class="card-sub">Channel status snapshots from the gateway.</div>
|
<div class="card-sub">Channel status snapshots from the gateway.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
|
<div class="muted">${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}</div>
|
||||||
</div>
|
</div>
|
||||||
${
|
${
|
||||||
props.lastError
|
props.lastError
|
||||||
@@ -308,7 +308,7 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last inbound</span>
|
<span class="label">Last inbound</span>
|
||||||
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
|
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
${
|
${
|
||||||
account.lastError
|
account.lastError
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { WhatsAppStatus } from "../types.ts";
|
import type { WhatsAppStatus } from "../types.ts";
|
||||||
import type { ChannelsProps } from "./channels.types.ts";
|
import type { ChannelsProps } from "./channels.types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||||
import { renderChannelConfigSection } from "./channels.config.ts";
|
import { renderChannelConfigSection } from "./channels.config.ts";
|
||||||
import { formatDuration } from "./channels.shared.ts";
|
|
||||||
|
|
||||||
export function renderWhatsAppCard(params: {
|
export function renderWhatsAppCard(params: {
|
||||||
props: ChannelsProps;
|
props: ChannelsProps;
|
||||||
@@ -38,19 +37,19 @@ export function renderWhatsAppCard(params: {
|
|||||||
<div>
|
<div>
|
||||||
<span class="label">Last connect</span>
|
<span class="label">Last connect</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
|
${whatsapp?.lastConnectedAt ? formatRelativeTimestamp(whatsapp.lastConnectedAt) : "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Last message</span>
|
<span class="label">Last message</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.lastMessageAt ? formatAgo(whatsapp.lastMessageAt) : "n/a"}
|
${whatsapp?.lastMessageAt ? formatRelativeTimestamp(whatsapp.lastMessageAt) : "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="label">Auth age</span>
|
<span class="label">Auth age</span>
|
||||||
<span>
|
<span>
|
||||||
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
|
${whatsapp?.authAgeMs != null ? formatDurationHuman(whatsapp.authAgeMs) : "n/a"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
|
import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts";
|
||||||
import type { CronFormState } from "../ui-types.ts";
|
import type { CronFormState } from "../ui-types.ts";
|
||||||
import { formatAgo, formatMs } from "../format.ts";
|
import { formatRelativeTimestamp, formatMs } from "../format.ts";
|
||||||
import { pathForTab } from "../navigation.ts";
|
import { pathForTab } from "../navigation.ts";
|
||||||
import { formatCronSchedule, formatNextRun } from "../presenter.ts";
|
import { formatCronSchedule, formatNextRun } from "../presenter.ts";
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ function formatStateRelative(ms?: number) {
|
|||||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||||
return "n/a";
|
return "n/a";
|
||||||
}
|
}
|
||||||
return formatAgo(ms);
|
return formatRelativeTimestamp(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobState(job: CronJob) {
|
function renderJobState(job: CronJob) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
ExecApprovalsFile,
|
ExecApprovalsFile,
|
||||||
ExecApprovalsSnapshot,
|
ExecApprovalsSnapshot,
|
||||||
} from "../controllers/exec-approvals.ts";
|
} from "../controllers/exec-approvals.ts";
|
||||||
import { clampText, formatAgo, formatList } from "../format.ts";
|
import { clampText, formatRelativeTimestamp, formatList } from "../format.ts";
|
||||||
|
|
||||||
export type NodesProps = {
|
export type NodesProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -130,7 +130,7 @@ function renderDevices(props: NodesProps) {
|
|||||||
|
|
||||||
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
||||||
const name = req.displayName?.trim() || req.deviceId;
|
const name = req.displayName?.trim() || req.deviceId;
|
||||||
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
|
const age = typeof req.ts === "number" ? formatRelativeTimestamp(req.ts) : "n/a";
|
||||||
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
|
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
|
||||||
const repair = req.isRepair ? " · repair" : "";
|
const repair = req.isRepair ? " · repair" : "";
|
||||||
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||||
@@ -189,7 +189,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
|||||||
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
|
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
|
||||||
const status = token.revokedAtMs ? "revoked" : "active";
|
const status = token.revokedAtMs ? "revoked" : "active";
|
||||||
const scopes = `scopes: ${formatList(token.scopes)}`;
|
const scopes = `scopes: ${formatList(token.scopes)}`;
|
||||||
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
|
const when = formatRelativeTimestamp(
|
||||||
|
token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null,
|
||||||
|
);
|
||||||
return html`
|
return html`
|
||||||
<div class="row" style="justify-content: space-between; gap: 8px;">
|
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||||
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||||
@@ -931,7 +933,7 @@ function renderAllowlistEntry(
|
|||||||
entry: ExecApprovalsAllowlistEntry,
|
entry: ExecApprovalsAllowlistEntry,
|
||||||
index: number,
|
index: number,
|
||||||
) {
|
) {
|
||||||
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
|
const lastUsed = entry.lastUsedAt ? formatRelativeTimestamp(entry.lastUsedAt) : "never";
|
||||||
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
|
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
|
||||||
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
|
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { html } from "lit";
|
import { html } from "lit";
|
||||||
import type { GatewayHelloOk } from "../gateway.ts";
|
import type { GatewayHelloOk } from "../gateway.ts";
|
||||||
import type { UiSettings } from "../storage.ts";
|
import type { UiSettings } from "../storage.ts";
|
||||||
import { formatAgo, formatDurationMs } from "../format.ts";
|
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||||
import { formatNextRun } from "../presenter.ts";
|
import { formatNextRun } from "../presenter.ts";
|
||||||
|
|
||||||
export type OverviewProps = {
|
export type OverviewProps = {
|
||||||
@@ -26,7 +26,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
const snapshot = props.hello?.snapshot as
|
const snapshot = props.hello?.snapshot as
|
||||||
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
|
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
|
||||||
| undefined;
|
| undefined;
|
||||||
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
|
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : "n/a";
|
||||||
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
|
||||||
const authHint = (() => {
|
const authHint = (() => {
|
||||||
if (props.connected || !props.lastError) {
|
if (props.connected || !props.lastError) {
|
||||||
@@ -198,7 +198,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-label">Last Channels Refresh</div>
|
<div class="stat-label">Last Channels Refresh</div>
|
||||||
<div class="stat-value">
|
<div class="stat-value">
|
||||||
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
|
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : "n/a"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||||
import { formatAgo } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
import { pathForTab } from "../navigation.ts";
|
import { pathForTab } from "../navigation.ts";
|
||||||
import { formatSessionTokens } from "../presenter.ts";
|
import { formatSessionTokens } from "../presenter.ts";
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ function renderRow(
|
|||||||
onDelete: SessionsProps["onDelete"],
|
onDelete: SessionsProps["onDelete"],
|
||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
) {
|
) {
|
||||||
const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a";
|
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||||
const rawThinking = row.thinkingLevel ?? "";
|
const rawThinking = row.thinkingLevel ?? "";
|
||||||
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
|
const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider);
|
||||||
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);
|
const thinking = resolveThinkLevelDisplay(rawThinking, isBinaryThinking);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { html, svg, nothing } from "lit";
|
import { html, svg, nothing } from "lit";
|
||||||
|
import { formatDurationCompact } from "../../../../src/infra/format-time/format-duration.ts";
|
||||||
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
|
import { extractQueryTerms, filterSessionsByQuery, parseToolSummary } from "../usage-helpers.ts";
|
||||||
|
|
||||||
// Inline styles for usage view (app uses light DOM, so static styles don't work)
|
// Inline styles for usage view (app uses light DOM, so static styles don't work)
|
||||||
@@ -2461,19 +2462,6 @@ function formatIsoDate(date: Date): string {
|
|||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDurationShort(ms?: number): string {
|
|
||||||
if (!ms || ms <= 0) {
|
|
||||||
return "0s";
|
|
||||||
}
|
|
||||||
if (ms >= 60_000) {
|
|
||||||
return `${Math.round(ms / 60000)}m`;
|
|
||||||
}
|
|
||||||
if (ms >= 1000) {
|
|
||||||
return `${Math.round(ms / 1000)}s`;
|
|
||||||
}
|
|
||||||
return `${Math.round(ms)}ms`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseYmdDate(dateStr: string): Date | null {
|
function parseYmdDate(dateStr: string): Date | null {
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -2500,23 +2488,6 @@ function formatFullDate(dateStr: string): string {
|
|||||||
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDurationMs(ms?: number): string {
|
|
||||||
if (!ms || ms <= 0) {
|
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
const totalSeconds = Math.round(ms / 1000);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
const minutes = Math.floor(totalSeconds / 60) % 60;
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
}
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadTextFile(filename: string, content: string, type = "text/plain") {
|
function downloadTextFile(filename: string, content: string, type = "text/plain") {
|
||||||
const blob = new Blob([content], { type });
|
const blob = new Blob([content], { type });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -3467,7 +3438,10 @@ function renderUsageInsights(
|
|||||||
stats.throughputCostPerMin !== undefined
|
stats.throughputCostPerMin !== undefined
|
||||||
? `${formatCost(stats.throughputCostPerMin, 4)} / min`
|
? `${formatCost(stats.throughputCostPerMin, 4)} / min`
|
||||||
: "—";
|
: "—";
|
||||||
const avgDurationLabel = stats.durationCount > 0 ? formatDurationShort(stats.avgDurationMs) : "—";
|
const avgDurationLabel =
|
||||||
|
stats.durationCount > 0
|
||||||
|
? (formatDurationCompact(stats.avgDurationMs, { spaced: true }) ?? "—")
|
||||||
|
: "—";
|
||||||
const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
|
const cacheHint = "Cache hit rate = cache read / (input + cache read). Higher is better.";
|
||||||
const errorHint = "Error rate = errors / total messages. Lower is better.";
|
const errorHint = "Error rate = errors / total messages. Lower is better.";
|
||||||
const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
|
const throughputHint = "Throughput shows tokens per minute over active time. Higher is better.";
|
||||||
@@ -3672,7 +3646,7 @@ function renderSessionsCard(
|
|||||||
parts.push(`errors:${s.usage.messageCounts.errors}`);
|
parts.push(`errors:${s.usage.messageCounts.errors}`);
|
||||||
}
|
}
|
||||||
if (showColumn("duration") && s.usage?.durationMs) {
|
if (showColumn("duration") && s.usage?.durationMs) {
|
||||||
parts.push(`dur:${formatDurationMs(s.usage.durationMs)}`);
|
parts.push(`dur:${formatDurationCompact(s.usage.durationMs, { spaced: true }) ?? "—"}`);
|
||||||
}
|
}
|
||||||
return parts;
|
return parts;
|
||||||
};
|
};
|
||||||
@@ -3976,7 +3950,7 @@ function renderSessionSummary(session: UsageSessionEntry) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="session-summary-card">
|
<div class="session-summary-card">
|
||||||
<div class="session-summary-title">Duration</div>
|
<div class="session-summary-title">Duration</div>
|
||||||
<div class="session-summary-value">${formatDurationMs(usage.durationMs)}</div>
|
<div class="session-summary-value">${formatDurationCompact(usage.durationMs, { spaced: true }) ?? "—"}</div>
|
||||||
<div class="session-summary-meta">${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}</div>
|
<div class="session-summary-meta">${formatTs(usage.firstActivity)} → ${formatTs(usage.lastActivity)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user